Skip to content

Connecting sources

A source is something tendant watches to figure out what needs doing — email, a calendar, and more over time.

tendant reads from each source you connect, extracts the things that look like tasks, and keeps them in one place. You stay in control of what it can see.

In keeping with the project’s principles, information only reaches an outside service when you’ve explicitly chosen to let it.

Two sources are fed by HTTP requests you point at tendant, rather than by tendant checking a provider:

  • webhook-in — anything that can POST JSON. Send POST /intake/webhook/<source id> with a body like {"idempotency_key": "evt-123", "payload": {"title": "Pay the invoice"}} (optional: disposition — forced_task, rich_event or llm_judge; reason; raw_ref; and for rich_event, confidence and stakes_hint).
  • pebble-in — a Pebble/CoreApp voice memo: the device POSTs its recording to /intake/pebble/<token>, and the transcription becomes a task (or, with filter.disposition, a candidate).

Every request must prove it knows the source’s webhook secret. Knowing the URL is not enough, and there is no “open” mode. tendant generates a secret (whsec_…) when you create the source; open Settings → Sources (or run the owner-only sourceWebhookSecret(sourceId:) query) to copy it and the URL. Then send it in any one of these ways:

HowHeader
Bearer tokenAuthorization: Bearer <secret>
Shared-secret headerX-Webhook-Secret: <secret> (for Pebble: whatever intake.pebble_secret_header names; this is the default)
Signature (webhook-in only)X-Signature-256: sha256=<hex HMAC-SHA256 of the raw body, keyed by the secret> — GitHub’s X-Hub-Signature-256 is accepted too

A request without a valid secret gets 401 and nothing is stored. If the secret leaks, rotate it (rotateSourceWebhookSecret); the old one stops working at once. To keep a secret stable across a database reset, pin it in tendant.toml with webhook_secret = "${env:MY_WEBHOOK_SECRET}" on the [[sources]] entry.

For Pebble, enter the source’s id as the <token> in the device’s webhook URL so memos go to that source exactly. With another token, tendant picks the pebble-in source whose secret the device sent. The older instance-wide intake.pebble_secret is still accepted alongside the per-source secrets.

Nothing is lost. tendant stores each delivery before it answers 202, so a crash or restart right after can’t drop it; a sender that got no answer just retries, and the retry is recognized by its idempotency_key (Pebble: the recording) and stored once. A disabled source answers 409.

Upgrading from a release before webhook secrets: existing webhook-in and pebble-in sources get a secret at the first start (the log names each one), and senders that don’t send it start getting 401. Copy the secret into each sender — or, for a Pebble already set up with intake.pebble_secret, nothing changes.

Events relayed by homefirst.cloud (Linear, Slack, Google) don’t use these routes: the cloud checks the provider’s own signature and passes the event over the server’s authenticated connection.

homefirst.cloud is Home First’s hosted proxy: it receives providers’ events for you (so they arrive within seconds instead of on a polling schedule) and lets you sign in to accounts with Home First’s registered apps. tendant works fully without it; every source falls back to polling.

The cloud recognizes this server, never you or your login: on first connect tendant generates its own key pair, keeps the private half encrypted with credentials.key, and gives the cloud only the public half. Everything sensitive the cloud passes on — a provider’s event, a sign-in token — is encrypted to that key before it leaves the cloud, and tendant keeps it encrypted until the moment it’s used.

  1. Set credentials.key (TENDANT_CREDENTIALS_KEY), the cloud’s address (HOMEFIRST_CLOUD_ADDR) and this server’s id (HOMEFIRST_SERVER_ID).
  2. Get a one-time enrollment code for the server id from whoever runs your homefirst.cloud. An operator gets it with pulumi stack output enrollmentCode-<server id> --show-secrets, or on the box with homefirst-cloud enroll-code <server id>.
  3. Either set it once as HOMEFIRST_CLOUD_ENROLLMENT_CODE (or put it in a file and set HOMEFIRST_CLOUD_ENROLLMENT_CODE_FILE) and start tendant, or open Settings → Connected accounts and choose Enroll to paste it in — no restart needed.
  4. Once the card shows Connected and a server key, the code is spent and tendant never needs it again: remove the environment variable. tendant never stores the code, and ignores it (logging that it can be removed) while the server is enrolled.

HOMEFIRST_CLOUD_TOKEN from earlier releases is no longer used; tendant warns at startup while it is still set. Remove it.

The homefirst.cloud card on the Connected accounts page shows whether the server is enrolled, its key id, when the key last rotated and when it rotates next, and what to do when something’s wrong:

  • Not enrolled — enter an enrollment code.
  • The code isn’t valid, was already used, or has expired — ask the operator for a new code. If the server was already enrolled, a rejected code changes nothing: it keeps its key.
  • homefirst.cloud no longer recognizes this server’s key (the operator removed the server, or its keys were lost) — Re-enroll with a new code from the operator. Nothing else is needed; sources keep polling meanwhile.
  • Signed requests outside the cloud’s time window — this server’s clock is off. Fix it (enable NTP); tendant keeps retrying.

tendant replaces its server key every 30 days on its own (cloud.key_rotation_interval; 0 turns the schedule off), and you can rotate it any time with Rotate key. A failed rotation is retried with growing delays and shown on the card. After a rotation the old key is kept for the cloud’s grace period (at least three days) plus one day of slack, so anything encrypted to it still opens — including events tendant has received but not processed yet, which keep the old key alive until they’re handled — and is then destroyed.

Some sources sign in to an account of yours: Google (Gmail, Calendar, Contacts, Drive), Slack and Linear. If this server is connected to homefirst.cloud you can connect these with a click, using Home First’s own registered apps — no developer console, no client secrets:

  1. Set server.public_url to the address you open tendant at, so the browser comes back to tendant when you’re done.
  2. In the app, open Settings → Connected accounts and choose Connect Google, Connect Slack or Connect Linear, then pick what tendant may access. Google asks for Gmail, Calendar and Contacts by default (Drive is optional); Slack for reading, searching and sending messages as you; Linear for reading and writing issues plus live events, which needs a workspace admin.
  3. Approve the request in the provider’s page. The account appears in the list once the authorization completes. If it doesn’t within a few minutes (the authorization window expired, or the connection to homefirst.cloud dropped at that moment), just connect again. The same goes for “this server couldn’t open the sign-in result”: the token was encrypted to a key the server no longer holds (it was just re-enrolled) — try connecting again.

The page offers only what your homefirst.cloud is set up for: the cloud tells tendant each time it connects which providers it can sign in to, which access options each has (and the exact permissions each option grants, shown in the picker), and which sources it can push events for. A provider the cloud isn’t set up for isn’t offered; while the cloud is unreachable, nothing is.

In Settings → Sources, each source that can receive pushed events says whether it currently does (push via homefirst.cloud) or is polling, and why — for example “homefirst.cloud doesn’t offer gmail push”. If the cloud stops offering a source after it restarts, tendant keeps the source’s subscription (in case it comes back) and checks on its schedule meanwhile.

Your tokens stay at home, encrypted with credentials.key. homefirst.cloud keeps Home First’s app secrets and never stores your tokens: when an access token expires, tendant sends its refresh token to the cloud for that one exchange and stores the new token. Neither side can act as you alone. If a provider stops honoring the grant (you revoked it, or it lapsed), the account shows Needs reconnecting — use Reconnect. Disconnect forgets the account and its tokens here; revoke tendant’s access in the provider’s own settings if you also want the grant gone there.

A source uses a connected account by naming it: account = "linear" (the one Linear account), an account’s label such as you@example.com, or its id.

You can connect as many accounts of a provider as you like — a work and a personal Gmail, two Slack users (in the same workspace or in different ones), two Linear workspaces. Each is its own row under Connected accounts, labelled so you can tell them apart (the Google address; for Slack you@workspace, e.g. brad@Acme). Reconnecting one never touches another.

Give each account its own source, naming it by its label or id (account = "work@example.com", account = "brad@Acme"). Each source gets its own push subscription and its own events. The provider shorthand (account = "google", "slack", "linear") only works while exactly one account of that provider is connected; with two it fails with an error listing them, rather than quietly picking one. If the same item reaches two of your accounts — a Slack message that mentions both of your Slack users, an email sent to both mailboxes — each account’s source surfaces it once, so you get one candidate per account (each says which inbox or Slack user it came through). Disconnecting an account stops its sources’ push and leaves every other account, and its sources, as they were.

Posting to Slack. The send-slack-message tool posts as a connected Slack account — messages appear as sent by that person, and every post still goes through your gate. Its optional account field names which one (label, id or team:user key). With one Slack account connected it may be left out; with several it is required, and a call without it is refused with the list of accounts to choose from. Before the gate sees a call, tendant writes the chosen account into it, so the approval request (and any gate script) shows exactly which identity will post. Without a Slack account connected, the tool only logs what it would have sent.

tendant can watch a Linear workspace and turn what concerns you into tasks:

  • An issue assigned to you becomes a task.
  • A comment that mentions you becomes a candidate for you to accept or dismiss.
  • A new issue in a team you watch (optional) becomes a candidate.

Add a linear source to tendant.toml that names your connected Linear account (account = "linear"), or give it a Linear API key instead (see the commented example in tendant.example.toml). Either way the credential is stored encrypted. Push needs a token allowed to create webhooks: the connected Linear account’s default scopes include it; an API key must belong to a workspace admin.

If this server is connected to homefirst.cloud, events arrive within seconds: the cloud receives Linear’s webhook, checks its signature, and forwards it to your home over its outbound connection. Without the cloud, tendant checks Linear on the source’s schedule instead — slower, but assignments still arrive. A candidate or task carries only the issue’s identifier, title and link (plus a short snippet of a mentioning comment), never the issue’s description.

With a Google account connected (see above), add a gmail, calendar or drive source that names it — account = "you@example.com", or account = "google" when only one Google account is connected (commented examples are in tendant.example.toml). Drive isn’t part of the default Google grant: tick it when you connect Google.

  • Gmail — each new message matching the source’s search query becomes a task (or a candidate, if you choose). It carries the sender, subject and Gmail’s short snippet.
  • Calendar — an invitation waiting for your answer becomes a task. A meeting with others that lands on your calendar, or moves, shortly before it starts (within two days by default) becomes a candidate, in case you need to prepare or rearrange. If an event tendant surfaced is cancelled, you get a candidate pointing at it. Only the title, time, organizer’s name and link are kept — never the description, location or attendees’ addresses.
  • Drive — a file newly shared with you becomes a candidate (its name, type, who shared it and the link). Drive comments that mention you aren’t covered; Google emails those to you, so the Gmail source sees them.

If this server is connected to homefirst.cloud, Google tells the cloud the moment something changes, the cloud checks that the notice really came from Google and passes it on, and tendant fetches what changed straight away. The notice itself carries nothing about your mail or events — tendant reads those from Google directly, with your own token. Without the cloud (or while it’s unreachable) each source checks Google on its schedule instead; while push works it checks only occasionally, as a safety net. Each item is surfaced once, however it was noticed.

A Gmail mailbox can be watched by only one home at a time. If another tendant server already watches yours, this one keeps checking on its schedule and logs why.

With a Slack account connected (see above), add a slack source that names it — account = "slack" when only one Slack account is connected, otherwise its label such as account = "brad@Acme" and one source per account (a commented example is in tendant.example.toml). tendant then sees what you can see in Slack and surfaces what is addressed to you:

  • A direct message to you from a person becomes a candidate. Many DMs are requests, many are just conversation, so tendant doesn’t decide on its own: with a triage model it judges the message, otherwise you accept or dismiss it.
  • A message that mentions you (@you, in a channel, private channel or group DM) becomes a candidate the same way — including a message someone edits to add your mention.
  • A reply in a thread you started becomes a low-priority candidate.
  • Messages in channels you list in the source’s channels (optional) become low-priority candidates even without a mention.

Your own messages, bots and apps, joins and leaves, @here/@channel broadcasts, and edits or deletions of messages already seen are ignored. You can make DMs or mentions straight tasks instead (dm_disposition, mention_disposition). A candidate keeps only who wrote it, where (the channel’s name, or “DM”), the first 280 characters of the message and a link to it in Slack — never the thread, files or anyone’s email address. (Set permalinks = "off" if you’d rather the link, which names your workspace and channel, not be kept.)

How messages arrive. Slack sends new messages to homefirst.cloud as they are posted; the cloud checks they really came from Slack and passes them to your home over its outbound connection. Unlike Google’s notices, these carry the message text — the cloud forwards it without reading or keeping it. Each message is surfaced once, however often it is delivered.

When push isn’t available (no homefirst.cloud, or it was unreachable), tendant catches up with Slack search on the source’s schedule: direct messages to you and messages mentioning you since its last look (going back at most a week, and only the newest 50 of each). While push works it still runs every six hours (push_poll_interval) as a safety net. tendant doesn’t read whole channel histories: Slack limits apps like Home First’s to one history request a minute and 15 messages, so search is the only practical catch-up. Thread replies and watched-channel messages missed while offline are not caught up. Turn the catch-up off with backfill = "off".

If you remove Home First from Slack (revoke its access, or a workspace admin uninstalls it), Slack tells the cloud, the account shows Needs reconnecting under Connected accounts, and tendant stops listening. Reconnect Slack to pick up again.