Threa Developers

Recipes

Worked examples.

Each one is a real task. Set your credentials in the panel (top right) and the runnable samples work as you read. The last wires a local agent into your workspace, the way the Pi extension does.

Find out what was decided, and why

Memos are Threa's record of decisions and context pulled from conversations. Search them by topic (semantic by default) and you get back titles, abstracts, tags, and links to the source messages.

search memos
curl -X POST /api/v1/workspaces//memos/search \
  -H "Authorization: Bearer " \
  -H "Content-Type: application/json" \
  -d '{
    "query": "why did we pause the auth refactor",
    "limit": 5
  }'

Take a memo's id from the results and pull its full provenance: the source stream and the exact messages, with authors and timestamps.

memo with sources
curl /api/v1/workspaces//memos/MEMO_ID \
  -H "Authorization: Bearer "

Notify a stream from CI

A build or deploy script can post into a channel. Give the message a stable clientMessageId so a retried pipeline step never double-posts. First find a stream to post into:

list channels
curl "/api/v1/workspaces//streams?type=channel&limit=20" \
  -H "Authorization: Bearer "

Then post, reusing the same ID across retries:

post once
curl -X POST /api/v1/workspaces//streams/STREAM_ID/messages \
  -H "Authorization: Bearer " \
  -H "Content-Type: application/json" \
  -d '{
    "content": "**Deploy:** v2.4.1 shipped to production. :rocket:",
    "clientMessageId": "ci-deploy-2.4.1"
  }'

Message search takes semantic and exact toggles and filters by stream, type, author, and date window. Drop it behind a slash command, an editor extension, or a dashboard.

search messages
curl -X POST /api/v1/workspaces//messages/search \
  -H "Authorization: Bearer " \
  -H "Content-Type: application/json" \
  -d '{
    "query": "rotation flow",
    "semantic": true,
    "type": ["channel", "thread"],
    "limit": 10
  }'

Connect your local agent

Threa runs a bot runtime protocol: your agent registers a presence, then claims work whenever someone @mentions its bot in a stream, does the work locally, and posts the reply back. It's pull-based, so your agent can live on your laptop or a Pi behind a NAT with no inbound ports. The Pi remote extension implements this in full.

1 · Create a bot and a bot key

In the app, create a bot (give it the mentionable trait so people can summon it), then mint a threa_bk_ key on it with bot-runtime:write, bot-invocations:write, messages:write, streams:read, messages:read, messages:search, memos:read, and attachments:read. Use that key as the credential below.

2 · Heartbeat a presence

Tell Threa your runtime is alive and accepting work. Repeat every 15–30s. The instanceId is any stable per-process string; runtimeKind is one of pi-local, hermes, openclaw, claude-code-channel, or custom.

heartbeat
curl -X POST /api/v1/workspaces//bot-runtime/presence \
  -H "Authorization: Bearer " \
  -H "Content-Type: application/json" \
  -d '{
    "runtimeKind": "custom",
    "instanceId": "my-laptop-1",
    "status": "available",
    "acceptingInvocations": true,
    "capabilities": { "supportsActiveScratchpad": false }
  }'

3 · Claim, work, reply

Poll for a pending invocation. When you get one, it carries the prompt and a claimToken; do your work, then complete it with a reply (or renew the claim first if you need longer than the TTL). A full loop in TypeScript:

agent-loop.ts
const BASE = ""
const WS = ""
const KEY = "" // a threa_bk_ bot key
const INSTANCE = "my-laptop-1"

const auth = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" }
const api = (path: string, body: unknown) =>
  fetch(`${BASE}/api/v1/workspaces/${WS}${path}`, {
    method: "POST",
    headers: auth,
    body: JSON.stringify(body),
  }).then((r) => r.json())

async function loop() {
  // Keep presence fresh in the background.
  setInterval(
    () =>
      api("/bot-runtime/presence", {
        runtimeKind: "custom",
        instanceId: INSTANCE,
        status: "available",
        acceptingInvocations: true,
        capabilities: {},
      }),
    20000
  )

  while (true) {
    const { data: inv } = await api("/bot-invocations/claim", {
      runtimeKind: "custom",
      instanceId: INSTANCE,
      supportedCapabilities: ["mentionable"],
      claimTtlSeconds: 120,
    })
    if (!inv) {
      await new Promise((r) => setTimeout(r, 3000))
      continue
    }

    // inv.promptMarkdown is what the user said. Do real work here.
    const reply = await runYourAgent(inv.promptMarkdown)

    await api(`/bot-invocations/${inv.id}/complete`, {
      instanceId: INSTANCE,
      claimToken: inv.claimToken,
      finalMessageMarkdown: reply,
    })
  }
}
Faster than polling There's also a Socket.IO /bot namespace that pushes bot_invocation:available events so you can react instantly and keep a poll only as a backstop. The Pi extension uses the socket with a 30s poll fallback. Read extensions/pi-remote/ in the repo for a complete implementation, including session-control commands like /model, /thinking, and /compact driven from a scratchpad.

With those read and search scopes, the bot can look through stream history, search messages and memos, and read attachments while it works. That gives your local agent the workspace context behind the message that summoned it.

Run a delegation

When Threa's assistant is asked for work that needs a real machine (fix a bug in a repo, run a migration), it doesn't pretend to do it in chat. It compiles a self-contained brief and posts a delegation card in the stream. The card waits, status open, until an agent claims it through this API. Every transition after that shows on the card live: claimed, running with progress notes, then completed with the result posted back into the conversation, where Threa's memory learns the outcome.

Works with either key kind: a personal threa_uk_ key posts the result as you (with a via-API badge); a workspace bot key posts it as the bot, the shared-runner setup. Both need the delegations:read and delegations:write scopes.

1 · Find open work

The queue is filtered to streams your key can access.

list open delegations
curl "/api/v1/workspaces//delegations" \
  -H "Authorization: Bearer "

2 · Claim it

The claim is atomic: exactly one claimer wins; a lost race returns 409 DELEGATION_NOT_OPEN. The response carries the full brief, the resolved context refs, and a claimToken on a 15-minute lease. The token is shown once and cannot be retrieved again.

claim
curl -X POST /api/v1/workspaces//delegations/DELEGATION_ID/claim \
  -H "Authorization: Bearer " \
  -H "Content-Type: application/json" \
  -d '{ "claimedByLabel": "build-box / my agent" }'

3 · Report progress, keep the lease alive

Send the token as an X-Threa-Callback-Token header on every later call. status puts a note on the card and renews the lease; heartbeat renews it silently. Let the lease lapse and the card flips to expired so nobody waits on a dead runner.

progress note
curl -X POST /api/v1/workspaces//delegations/DELEGATION_ID/status \
  -H "Authorization: Bearer " \
  -H "X-Threa-Callback-Token: CLAIM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "statusNote": "Tests passing, writing the migration" }'

4 · Complete with the result

Completion posts a compact anchor into the stream with the full result as a thread reply under it, and the card flips in the same transaction. Optional metadata is stamped on the result message (queryable later with find-by-metadata). Retries are safe: a retried complete bearing the same token returns the committed outcome instead of double-posting.

complete
curl -X POST /api/v1/workspaces//delegations/DELEGATION_ID/complete \
  -H "Authorization: Bearer " \
  -H "X-Threa-Callback-Token: CLAIM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "resultMarkdown": "Shipped in PR #42. All acceptance criteria pass.",
    "metadata": { "github.pr": "https://github.com/acme/repo/pull/42" }
  }'

Something went wrong instead? POST …/fail with an errorMessage puts the reason on the card. And the card's own Copy prompt button embeds these instructions with the real ids filled in, so an agent that receives the pasted brief knows how to claim it.

Connect an encrypted agent

An end-to-end-encrypted scratchpad stores only ciphertext: the server never sees the prompt, the reply, or a single trace step. Your agent holds the key, so it can send the exact command, the full diff, and real output. Same claim-and-reply loop as above, with a sealing layer on top. Three things to get right: a per-machine identity key, the AAD that binds each ciphertext, and the two-phase handshake that mints the stream key.

The format Suite is RFC 9180 DHKEM(X25519, HKDF-SHA256) + HKDF-SHA256 + AES-256-GCM. A stream owns one symmetric key (SSK) per generation; the owner HPKE-wraps it to each recipient's public key. Every ciphertext is AES-256-GCM under the SSK, with additional-authenticated-data that pins it to its slot. Get these byte-exact or GCM verification fails:
wrap: streamId|keyGeneration|recipientKeyId  ·  message & step: streamId|messageId|senderId
An envelope is { v: 2, keyGeneration, iv, aad } (iv/aad base64); the ciphertext travels beside it.

A ~40-line helper covers the crypto. The X25519 KEM is pure-JS so it runs on any runtime (some, like Bun, lack native X25519):

sealed.ts
import { Aes256Gcm, CipherSuite, HkdfSha256 } from "@hpke/core"
import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519"

const suite = new CipherSuite({
  kem: new DhkemX25519HkdfSha256(),
  kdf: new HkdfSha256(),
  aead: new Aes256Gcm(),
})
const utf8 = (s: string) => new TextEncoder().encode(s)
const b64 = (u: Uint8Array) => btoa(String.fromCharCode(...u))
const unb64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0))
const wrapAad = (stream: string, gen: number, keyId: string) => utf8(`${stream}|${gen}|${keyId}`)
const msgAad = (stream: string, id: string, sender: string) => utf8(`${stream}|${id}|${sender}`)

// Your identity key (BIK): one X25519 keypair per machine, persisted 0600.
// NOT derived from the API key; a leaked key must not expose past scratchpads.
export async function makeBik() {
  const kp = await suite.kem.generateKeyPair()
  return {
    publicKeyId: `bik_${crypto.randomUUID()}`,
    publicKey: b64(new Uint8Array(await suite.kem.serializePublicKey(kp.publicKey))),
    privateKey: kp.privateKey,
  }
}
const importPub = async (pub: string) => suite.kem.deserializePublicKey(unb64(pub).buffer)

// Owner (or you, at create) wraps an SSK to a recipient's public key.
export async function wrapSSK(ssk: Uint8Array, pub: string, aad: Uint8Array) {
  const s = await suite.seal({ recipientPublicKey: await importPub(pub) }, ssk, aad)
  return { wrapEnc: b64(new Uint8Array(s.enc)), wrapCt: b64(new Uint8Array(s.ct)) }
}
type KeyWrap = { wrapEnc: string; wrapCt: string }
export const unwrapSSK = async (wrap: KeyWrap, priv: CryptoKey, aad: Uint8Array) =>
  new Uint8Array(await suite.open({ recipientKey: priv, enc: unb64(wrap.wrapEnc) }, unb64(wrap.wrapCt), aad))

// Open / seal an SSK-encrypted message or step.
async function aesKey(ssk: Uint8Array, use: "encrypt" | "decrypt") {
  return crypto.subtle.importKey("raw", ssk, "AES-GCM", false, [use])
}
type Envelope = { iv: string; aad: string }
export async function open(ssk: Uint8Array, env: Envelope, ct: string) {
  const key = await aesKey(ssk, "decrypt")
  const pt = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv: unb64(env.iv), additionalData: unb64(env.aad) },
    key,
    unb64(ct)
  )
  return new TextDecoder().decode(pt)
}
export async function seal(ssk: Uint8Array, stream: string, id: string, sender: string, gen: number, text: string) {
  const iv = crypto.getRandomValues(new Uint8Array(12))
  const aad = msgAad(stream, id, sender)
  const key = await aesKey(ssk, "encrypt")
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad }, key, utf8(text)))
  return { ciphertext: b64(ct), envelope: { v: 2, keyGeneration: gen, iv: b64(iv), aad: b64(aad) } }
}

1 · Register your identity key

Generate the BIK once, persist it, and send its public half on every presence write. The server overwrites the stored key each time, so omitting it un-registers you.

presence with a key
const bik = await loadOrMakeBik() // makeBik(), then reuse across restarts
await api("/bot-runtime/presence", {
  runtimeKind: "custom",
  instanceId: INSTANCE,
  status: "available",
  acceptingInvocations: true,
  publicKey: bik.publicKey,
  publicKeyId: bik.publicKeyId,
})

2 · Create an encrypted scratchpad

Two-phase, because the wrap AAD binds to the stream id the server mints. Ask for the owner's public key, create the session encrypted, then wrap a fresh generation-0 SSK to the owner and to yourself:

two-phase create
// Owner's key. A 404 means they haven't set up encryption yet; tell the user.
const { data: owner } = await get("/bot-runtime/owner-e2e-key") // { keyId, publicKey }

const { data: link } = await api("/bot-runtime/sessions", {
  runtimeKind: "custom",
  instanceId: INSTANCE,
  runtimeSessionId: SESSION,
  displayName: "My sealed agent",
  e2e: { ownerKeyId: owner.keyId },
})
// link.e2eEnabled === true, link.rootStreamId is the stream that owns the key.

const ssk = crypto.getRandomValues(new Uint8Array(32))
const wraps = [
  {
    recipientKind: "user",
    recipientKeyId: owner.keyId,
    ...(await wrapSSK(ssk, owner.publicKey, wrapAad(link.rootStreamId, 0, owner.keyId))),
  },
  {
    recipientKind: "bot",
    recipientKeyId: bik.publicKeyId,
    ...(await wrapSSK(ssk, bik.publicKey, wrapAad(link.rootStreamId, 0, bik.publicKeyId))),
  },
]
await api(`/streams/${link.rootStreamId}/e2e/key-wraps`, { keyGeneration: 0, wraps })

3 · Claim, then open with your key

Claim as usual. On an encrypted stream the response carries a sealedContext instead of a plaintext prompt: the SSK wraps addressed to your BIK, the sealed trigger, and a per-turn callbackToken that authorizes your sealed writes.

open a sealed claim
const { data: inv } = await api("/bot-invocations/claim", {
  runtimeKind: "custom",
  instanceId: INSTANCE,
  supportedCapabilities: ["active-scratchpad"],
  claimTtlSeconds: 120,
})
const sc = inv.sealedContext
const stream = inv.rootStreamId

// Recover the SSK for each generation the owner wrapped to you.
const ssks = new Map<number, Uint8Array>()
for (const w of sc.wraps)
  ssks.set(w.keyGeneration, await unwrapSSK(w, bik.privateKey, wrapAad(stream, w.keyGeneration, bik.publicKeyId)))

const promptSSK = ssks.get(sc.prompt.envelope.keyGeneration)!
const prompt = await open(promptSSK, sc.prompt.envelope, sc.prompt.ciphertext)
// prompt is the real user message. Run your agent on it.

4 · Seal every reply and step

Seal under the reply generation's SSK, bound to a fresh id, and post to the sealed endpoints with the callback token in the X-Threa-Callback-Token header. Steps carry a step_ id, the reply a msg_ id.

seal back
const gen = sc.reply.keyGeneration
const sender = sc.reply.senderId
const ssk = ssks.get(gen)!
const sealed = (id: string, text: string) => seal(ssk, stream, id, sender, gen, text)
const cb = { "X-Threa-Callback-Token": sc.callbackToken, "Content-Type": "application/json" }
const post = (path: string, body: unknown) =>
  fetch(`${BASE}/api/v1/workspaces/${WS}${path}`, { method: "POST", headers: cb, body: JSON.stringify(body) })

// A trace step: the full command, the real output. The server can't read it.
const stepId = `step_${crypto.randomUUID()}`
await post(`/bot-invocations/${inv.id}/sealed-steps`, {
  stepId,
  stepType: "tool_call",
  ...(await sealed(stepId, `$ rm -rf ./build\n${output}`)),
})

// The final reply, then the turn is done.
const msgId = `msg_${crypto.randomUUID()}`
await post(`/bot-invocations/${inv.id}/sealed-complete`, {
  reply: { messageId: msgId, ...(await sealed(msgId, replyMarkdown)) },
})
Steps over the socket High-volume steps go over the Socket.IO /bot namespace as bot:invocation:sealed-steps ({ invocationId, callbackToken, steps: [frame] }), so a chatty turn never bills the edge worker. Mid-turn progress messages seal to /bot-invocations/:id/sealed-messages the same way. The full request schemas are in the reference; the Pi (extensions/pi-remote/) and Claude Code (extensions/claude-code-remote/) harnesses are complete implementations.

Re-keying is the owner's: inviting or removing a recipient rolls the generation and re-wraps the new SSK to the current set, so a removed agent keeps old history but reads nothing new. Your key never leaves your machine, and nothing you send is ever stored in the clear.