Integrations
Every external service the application talks to falls into one of two categories. Per-workspace integrations live as encrypted credentials on the workspace row, configured from each workspace's /integrations page. They never leak across workspaces. Global integrations live in environment variables on the server and apply to every workspace — they're for services where the credential is a shared piece of infrastructure (a Notion internal integration token, the Google OAuth app's client id, an OpenAI key) rather than a per-account credential.
This document covers each integration end-to-end: what setup the user has to do, what the app actually does with the credential, what gets synced into Postgres, and what stays at the source. Read top to bottom on first setup; on day-to-day operation only the per-workspace section is interesting.
Hacker News
Setup: enter your HN username on the integrations page. No API key, no OAuth — Hacker News exposes a public Firebase-backed JSON API that anyone can read with just a username. Comma-separated usernames work too (rohansrma, tessl) so you can pull both your personal handle and a company handle into the same workspace.
What it syncs: every story you've ever submitted (becomes HACKERNEWS_POST content records, classified as ask / show / post HN based on the title prefix) and every comment you've written (becomes HACKERNEWS_COMMENT records grouped under their root story). The "Add by link" button on the content toolbar accepts any HN URL (story or comment) and pulls just that single item.
Sync schedule: every three hours by the in-process cron. Each run upserts on (workspaceId, externalId) so the same item never duplicates; upvotes (HN score) and comments (descendant count) are refreshed on every sync so the analytics charts stay current.
Setup: enter your Reddit username (or comma-separated list) on the integrations page. Like HN, no auth required — Reddit's public JSON API gives anonymous access to a user's posts and comments, just with tight rate limits.
What it syncs: posts (REDDIT_POST), top-level comments (REDDIT_COMMENT), and nested replies (REDDIT_REPLY). The score, upvote ratio, comment count, and subreddit are pulled and stored alongside each row. The "Add by link" button accepts any Reddit URL.
Sync schedule: refresh runs only on demand because Reddit's anonymous API rate-limits aggressively and there's no operational benefit to polling. Click Sync on the integration card or the content toolbar to refresh; otherwise add new items by link.
dev.to
Setup: connect with your dev.to API key from Settings → Extensions on dev.to. Without the key the integration falls back to dev.to's public listing endpoint, which is enough to pull article metadata but not page views, reactions detail, or drafts. With the key you get full analytics. Each workspace stores its own API key encrypted with TOKEN_ENC_KEY — there is no global env override.
What it syncs: every published article (and every draft when the API key is connected) as a BLOG_POST content record with platform: DEV_TO. The article's markdown body, canonical URL, organization publication (if any, stored in community), tags, page views, reactions, and comments are all pulled. Titles are prefixed Repurpose: so the content table makes it obvious which rows came from dev.to and what they're for.
Sync schedule: once a day at 03:30 by the in-process cron. The schedule runs two passes per workspace: first syncDevtoForWorkspace walks newest-first and stops at the first known article (cheap on subsequent runs), then refreshDevtoForWorkspace walks every existing row and refreshes the API-driven fields. Refresh only touches articles the workspace's API key owns — articles added by URL from a different author have their metrics left alone so manually-edited values don't get clobbered.
The shares field is special: dev.to's API does not expose per-article bookmark counts, so the sync never touches shares. Whatever you type in the content detail drawer survives every sync indefinitely.
Linear
Setup: generate a personal API key in Linear at Settings → API → Personal API keys. Paste it into the Linear integration card on the workspace's integrations page. Each workspace stores its own key encrypted with TOKEN_ENC_KEY — no global env override, no shared key.
What it syncs: every Linear team you have access to (becomes LinearTeam rows), every issue (LinearIssue rows with denormalised state, assignee, labels, due date, parent / sub-issue links so the board renders instantly), every issue template you've defined (LinearIssueTemplate rows), and every Linear notification (Notification rows with source: LINEAR that deep-link into the in-app Linear tab instead of opening linear.app). The sync is idempotent — re-running it never duplicates an issue.
Sync schedule: refresh on demand. Linear's GraphQL API rate-limits per-key, so polling isn't free; the integration runs when the owner clicks Sync on the integration card, when the notifications page is refreshed, or when a new comment is posted from inside the app.
Mutations (create issue, update issue, archive, post comment, reply to comment, save template, apply template) are owner-only and go through Linear's API in real time. Viewers see issues, sub-issues, comments, labels, and assignees but cannot create or modify anything.
PostHog
Setup: create a personal API key in PostHog under Settings → Personal API keys with the query:read and project:read scopes, then paste it into the PostHog card on the workspace's integrations page along with the numeric project id from your PostHog URL (app.posthog.com/project/12345). The host field is optional — leave it blank for US cloud, or set https://eu.posthog.com or your self-hosted URL. Each workspace stores its own key encrypted with TOKEN_ENC_KEY; there is no global env override, so different workspaces can point at entirely different PostHog accounts.
What it syncs: nothing is stored. Traffic is queried live via PostHog's HogQL query API each time an analytics page loads. $pageview events are grouped by day and classified into Reddit, Hacker News, dev.to, LinkedIn, and X purely from properties.$referring_domain — no UTM tagging required on your links. Mobile-app referrers are matched too (com.reddit.frontpage, com.linkedin.android), and X covers t.co, twitter.com, and x.com. The source list lives in src/lib/traffic.ts; adding a new one means adding an entry there plus a branch in the SOURCE_CASE multiIf in src/server/integrations/posthog.ts.
Where it shows up: the "Referral traffic" panel on the workspace analytics page and the overlaid traffic area on the content analytics chart. When PostHog is connected the traffic KPI switches from the stored contentRecord.traffic column to the live PostHog sum.
Gotcha: HogQL queries default to roughly 100 rows. Long date ranges grouped by day and source blow past that and silently truncate, so the traffic query pins LIMIT 100000 explicitly.
Notion
Setup: the token is global, the parent page is per-workspace. Create an internal integration at notion.so/profile/integrations, give it read access to whatever pages you want mirrored, copy the secret, and put it in NOTION_TOKEN in your env. Then, on each workspace that wants Notion documents, paste the parent page ID (or the full page URL) into the Notion card on the workspace's integrations page.
What it syncs: child pages of the configured parent become Document rows with provider: NOTION. Clicking a Notion document in the documents list fetches the page's blocks on demand and renders them inline — no need to bounce to Notion.so. Updates inside the app push back to Notion via the REST API when supported (text edits do; tables, embeds, and toggles are read-only at the moment).
Sync schedule: pull on demand. Notion's API doesn't push so the cheapest model is to fetch the latest blocks each time the user opens the document.
Google Docs and Sheets
Setup: this is partly global, partly per-workspace. The OAuth client itself (the thing that defines what your app is asking permission for) is global — set GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET in env, enable the Drive API, Docs API, Sheets API, and Calendar API in the same Google Cloud project, and authorise the redirect URI <APP_URL>/api/integrations/google/callback. Then each workspace owner clicks "Connect Google" on their integrations page, completes the OAuth dance, and the workspace gets its own encrypted refresh token. Each workspace can connect a different Google account.
What it syncs: Drive files in the configured folder become Document rows for .gdoc files and Spreadsheet rows for .gsheet files. Documents you've been shared on (but don't own) surface as SharedDocOffer notifications you can claim into a workspace.
How the embed works: owners load Drive's /edit?embedded=true URL inside an iframe — the full editable Google Docs / Sheets UI ships with the page, so the owner can type, format, comment, and share without leaving the app. Viewers load /preview instead, which is the read-only viewer; /edit?embedded=true would force them through a Google sign-in screen.
Third-party cookies (important): the embed authenticates the owner via cookies on docs.google.com. Chrome's default "Block third-party cookies" setting hides those cookies from the iframe, producing "Allow Google Docs access to your necessary cookies" or "Can't access your Google Account" in place of the editor. There is no server-side fix because the cookie policy is enforced by the browser against Google's own origin, not ours. Each user has to allow third-party cookies for [*.]google.com once per browser profile:
- Open Chrome → ⋮ → Settings → Privacy and security → Third-party cookies
- Either pick "Allow third-party cookies" globally, or
- Stay on "Block third-party cookies" and click "Add" under Sites allowed to use third-party cookies — add the pattern
[*.]google.com. During local development also add[*.]localhost(or whatever hostAPP_URLresolves to) so the embed can run cookies for the parent page too.
Refresh the document page after changing the setting — the editable Drive UI loads with the owner's account session.
Sync schedule: pull on demand. Click Sync on the documents or sheets page to refresh the Drive folder; click Refresh on the notifications page to scan for new shared-document offers.
Google Calendar / Google Meet
Setup: same Google OAuth flow as Docs / Sheets. The calendar scope (https://www.googleapis.com/auth/calendar.events) is requested alongside Drive and Docs scopes. If you connected Google before the calendar scope was added you'll need to disconnect and reconnect once to grant it — the integration shows a clear error message telling you so.
What it shows: the upcoming 14-day window of events on your primary Google calendar appears in the Meetings widget on /home, capped at six. The widget also displays them inside /calendar's month / week view alongside content. Each row links straight to the Meet conference if one is attached.
What it does: owners can create a new event with attendees and a Google Meet conference link from the home widget's "New" button — the event lands on the primary calendar with conferenceData.createRequest so Google attaches a Meet link automatically. Optionally send a calendar invite email by ticking the "Email attendees" checkbox in the create dialog. Owners can also edit and cancel events; viewers can only join.
The same widget will swap to Microsoft Teams or Zoom in the future depending on the workspace; the integration code is structured so adding a new provider only requires writing the equivalent of createMeeting / updateMeeting / deleteMeeting against that provider's API. The UI is provider-agnostic.
OpenAI (work-log summaries)
Setup: set OPENAI_API_KEY in env. Optional — without it the daily work log falls back to a deterministic template summary (still useful, just less narratively interesting).
What it does: every night at 23:55, for each workspace that doesn't already have a manual summary for today, the cron computes the day's activity (tasks done, content created, content published, Linear issues closed) and asks gpt-4o-mini to write a one-paragraph summary in the owner's voice. The same call is available on demand via the "Generate" button in the work tracker.
Manual summaries are never overwritten. If you type your own writeup, the cron sees a non-empty summary and skips that workspace for the day.
Per-workspace versus global summary
A quick reference for which credential lives where:
| Provider | Where the credential lives |
|---|---|
| Hacker News (handle) | Per-workspace |
| Reddit (handle) | Per-workspace |
| dev.to (API key) | Per-workspace |
| Linear (API key) | Per-workspace |
| Notion (token) | Global env (NOTION_TOKEN) |
| Google OAuth client | Global env (GOOGLE_OAUTH_CLIENT_ID + _SECRET) |
| Google refresh token (per account) | Per-workspace |
| OpenAI (API key) | Global env (OPENAI_API_KEY) |
The split exists because some credentials are intrinsically global (the OAuth client identifies your app to Google, not a particular user's account) and others are intrinsically personal (your Linear API key authenticates as you specifically and should never be shared across workspaces that might represent different clients or projects).
Manual "Add by link"
Independent of any sync, each of Hacker News, Reddit, and dev.to offers a small icon button on the content page toolbar that takes a single URL and adds that one item. Useful for content that the public listing missed, items from accounts other than the one you're syncing, or anything you want to track without setting up a full integration. The button works for all three providers; you don't need to configure anything beyond pasting a URL.
Provider-agnostic abstractions
A few helpers worth knowing about if you're extending the integrations:
gfetch(workspaceId, url, init)inserver/integrations/google-docs.tsis the shared Google API client — it manages OAuth token refresh, caches access tokens for ~50 minutes, and surfaces precise error messages (insufficient scope, expired token, API not enabled). Calendar, Docs, Sheets, and Drive all use it.withLinearKey(workspaceId, fn)inserver/services/linear-sync.tsdecrypts the workspace's Linear API key once, passes it to the callback, and wraps any error into a consistent shape.resolveDevtoApiKey(encryptedKey)returns the decrypted dev.to key for a workspace or null if not connected.requireSameOrigin(req)inserver/ratelimit.tsis the CSRF guard every mutating API route calls.
Add a new integration by following the same shape: store the credential on the workspace row (encrypted if it's sensitive), write a service module under server/services/ for the sync, expose a server action under app/(app)/w/[workspace]/integrations/actions.ts plus optionally a card under features/integrations/, and hook the sync into src/server/cron.ts if it needs to run on a schedule.