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.
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.
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:
curl "/api/v1/workspaces//streams?type=channel&limit=20" \
-H "Authorization: Bearer " Then post, reusing the same ID across retries:
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"
}' Mirror a search into your own tool
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.
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.
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:
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,
})
}
} /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.
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.
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.
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.
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.
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):
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.
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:
// 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.
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.
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)) },
}) /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.