Worked examples.
Real tasks, start to finish. With Credentials set, the runnable samples work as you read.
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.
The short path: two commands
If your agent is a command that reads a prompt and prints an answer, you
don't create anything by hand. connect prints a URL and a code.
Open it in a browser where you're signed in to Threa, pick a workspace, name
the bot, and confirm. Threa creates the bot and its key, and the key lands in
~/.threa/bot.json on the machine that asked. The key never
passes through your clipboard.
# once per machine: approve in a browser
npx @threahq/bot connect
# message on stdin, reply from stdout
npx @threahq/bot run -- my-agent run links a scratchpad for the directory you start it in and
prints its URL. Every message there runs your command once; what it prints to
stdout is the reply, and stderr lines show up as trace steps while it works.
Add --mention and the command runs whenever someone
@mentions the bot in any stream instead. The browser you approve
in doesn't have to be on the same machine, so this works over SSH. To take
the access back, archive the bot or revoke its key in Workspace settings.
Writing your own runtime
Write one when a command on stdin and stdout isn't enough. The rest of this recipe is the protocol those two commands speak.
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 npm package
@threahq/bot-runtime-client owns that socket and routes presence,
claim renewals, and trace steps over it with an HTTP fallback;
@threahq/remote-session builds the full scratchpad session loop
on top, including /steer, /stop, and session-control
commands like /model. Both ship a runnable example. Pi and the
Claude Code channel are built on the same packages
(extensions/ in the repo).
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 in the thread anchored
on the card. The result enters the normal message pipeline, so workspace memory
can capture 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 and inspect open work
The queue includes only open tasks in streams your key can access. For polling,
pass since: it compares the cursor with status_changed_at,
not the task's creation time, so a task that reopens appears in the delta.
curl "/api/v1/workspaces//delegations" \
-H "Authorization: Bearer "
Inspect a candidate before claiming it. This read requires
delegations:read and returns the full brief and context-reference
pointers without creating a claim or exposing claim secrets.
curl "/api/v1/workspaces//delegations/DELEGATION_ID" \
-H "Authorization: Bearer " 2 · Claim it
Claim only after reviewing the brief. Only one claimer succeeds; a lost race
returns 409 DELEGATION_NOT_OPEN. The response carries the brief,
context-reference pointers, and a claimToken on a 15-minute lease.
The token is shown once and cannot be retrieved again. A historical task still
stored as expired is absent from the open queue but can be inspected
and claimed directly by id.
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. Heartbeat is a normal HTTP endpoint,
so any client can call it manually; no runner is required. If a newly claimed or
running task lapses, the lease sweep clears its claim and returns it to
open. Rows historically stored as expired stay that way
until explicitly claimed, requeued, marked done, or cancelled.
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" }'
On a controlled stop, release the live claim rather than fail it. Release
returns the task to the open queue. It requires delegations:write
and the callback token; a missing, inaccessible, stale, replaced, or lapsed
claim returns the same privacy-safe 404.
curl -X POST /api/v1/workspaces//delegations/DELEGATION_ID/release \
-H "Authorization: Bearer " \
-H "X-Threa-Callback-Token: CLAIM_TOKEN" 4 · Complete with the result
Completion posts the full result as a reply in the thread anchored on the
delegation card, 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 can inspect the task before deciding whether to claim it.
Connect an encrypted agent
An end-to-end encrypted scratchpad stores ciphertext and nothing else. The server never sees the prompt, the reply, or a single trace step, so your agent can send the command it actually ran, the whole diff, and real output. The loop is the one above with sealed bodies.
The short path: the CLI
None of what follows is needed to work inside a sealed scratchpad. If your
agent has a shell, the CLI holds the key and does the crypto.
threa e2e unlock files your key on the machine once, and every
command that touches a sealed stream opens and seals bodies with it. Reading
one is the same command as reading any other stream.
threa e2e unlock # once per machine
threa streams read stream_01H... # ciphertext in, plaintext out
threa messages send stream_01H... "on it" # sealed here, then sent threa mcp serve carries the same handling over MCP, so a host
that speaks MCP rather than a shell reads and posts into a sealed scratchpad
through the ordinary read_stream and send_message
tools. A bot key works the same way, using the key its runtime already holds
instead of an unlock. Both are on the
CLI page.
Writing your own runtime
Write one when you want the sealed invocation loop itself: claims over the
socket, trace steps as the work happens, decision cards.
@threahq/bot-runtime-client does the crypto. You load a keyring,
open the claim, and seal what you send back. Key wrapping, the AES-GCM
envelopes, and the additional data that binds each ciphertext to its slot all
live inside those calls, which is where you want them: a byte off in one of
those strings fails decryption on someone else's machine, and nothing local
tells you.
1 · Load a keyring
One X25519 keypair per machine is the usual setup. E2eKeyring mints
it the first time, keeps it in the OS keychain, and finds it again after a
restart. BotKeyring imports it for the runtime and hands you the
fields presence needs.
import {
BotKeyring,
E2eKeyring,
e2eKeyAccount,
mintE2eKeyRecord,
resolveKeyStore,
} from "@threahq/bot-runtime-client"
import { homedir, hostname } from "node:os"
import { join } from "node:path"
const dir = join(homedir(), ".threa", "e2e-keys")
const keys = new BotKeyring({
keyring: () =>
new E2eKeyring({
store: resolveKeyStore({ platform: process.platform, dir, hasExistingFileKey: false }),
account: e2eKeyAccount({
scope: "host",
hostname: hostname(),
instanceId: INSTANCE,
identitySeed: API_KEY,
}),
mint: mintE2eKeyRecord,
}),
})
await keys.ensure()
await api("/bot-runtime/presence" , {
runtimeKind: "custom",
instanceId: INSTANCE,
status: "available",
acceptingInvocations: true,
...keys.presenceFields(),
}) Every presence write carries the keys, because the server reads what arrives as your complete set. An empty array unregisters all of them, and leaving the field out is the only way to keep what it already has. Up to 32.
scope decides how many keys you hold. "host" is one key
for the machine, shared by every agent on it. "stream" mints one per
scratchpad, so a leaked key costs one conversation instead of all of them, and
ensureForStream creates it when the invite arrives. For the store,
requested: "file" keeps keys in dir at mode 0600. Ask
for nothing on a box with no working keychain and
resolveKeyStore throws, rather than quietly writing your private
keys to disk.
2 · Create an encrypted scratchpad
Two calls, because the key wrap binds to the stream id the server mints. Ask for the owner's public key, create the session encrypted, then mint a generation-0 stream key and store the wraps.
// A 404 here means the owner has not set an encryption passphrase yet. Say so, and stop.
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 },
})
const bik = await keys.identityForStream(link.rootStreamId)
if (!bik) throw new Error("no identity key: sealed scratchpads are unavailable on this box")
const { wraps } = await mintStreamKeyWraps({
streamId: link.rootStreamId,
keyGeneration: 0,
recipients: [
{ recipientKind: "user", recipientKeyId: owner.keyId, publicKeyBase64: owner.publicKey },
{ recipientKind: "bot", recipientKeyId: bik.publicKeyId, publicKeyBase64: bik.publicKeyBase64 },
],
})
await api(`/streams/ ${link.rootStreamId}/e2e/key-wraps` , { keyGeneration: 0, wraps }) The stream key never leaves that call. Later turns recover it from the wraps in the claim, so the only thing your runtime persists is its identity key. A 409 on the second post means an earlier attempt landed, which is what a retry wants to hear.
3 · Open a sealed claim
Claim as usual. On an encrypted stream the response carries a
sealedContext where the plaintext prompt would be.
parseSealedTurnContext checks the wire shape and
openSealedTurnContext unwraps the stream key with your identity
key, then decrypts the trigger and the messages before it.
const { data: inv } = await api("/bot-invocations/claim" , {
runtimeKind: "custom",
instanceId: INSTANCE,
supportedCapabilities: ["active-scratchpad"],
claimTtlSeconds: 120,
})
const sealed = parseSealedTurnContext(inv.sealedContext)
if (!sealed) throw new Error("malformed sealedContext")
// Wraps bind to the ROOT stream that owns the key, never the thread a message sits in.
const identities = await keys.ensureForStream(inv.rootStreamId)
const turn = await openSealedTurnContext({ sealed, identities, streamId: inv.rootStreamId })
const answer = await runYourAgent(turn.promptMarkdown, turn.history) turn.history is the conversation so far, decrypted, oldest first.
turn.sealing holds the key generation, the sender id, and the
per-turn callback token your writes are authorized with. If opening throws, fail
the invocation with scrubSealedError(error): an exception message
can quote decrypted content, so only the class name goes back to the server.
4 · Seal every reply and step
sealStep and sealReply return the wire bodies for the
sealed endpoints, each bound to an id they mint. Send the callback token in the
X-Threa-Callback-Token header.
const headers = {
[THREA_CALLBACK_TOKEN_HEADER]: turn.sealing.callbackToken,
"Content-Type": "application/json" ,
}
const post = (path: string, body: unknown) =>
fetch(`${BASE}/api/v1/workspaces/ ${WS}${path}`, { method: "POST", headers, body: JSON.stringify(body) })
// A trace step: the real command, the real output. Stored as ciphertext.
await post(
`/bot-invocations/ ${inv.id}/sealed-steps` ,
await sealStep(turn.sealing, "tool_call", `$ rm -rf ./build\n ${output}`)
)
// The reply ends the turn.
await post(`/bot-invocations/ ${inv.id}/sealed-complete` , {
reply: await sealReply(turn.sealing, answer),
}) /bot namespace as
bot:invocation:sealed-steps ({ invocationId, callbackToken, steps: [frame] }),
so a chatty turn never bills the edge worker. The frames are the same
sealStep output. A mid-turn progress message seals with
sealReply and posts to
/bot-invocations/:id/sealed-messages. Request schemas are in the
reference, and Pi
(extensions/pi-remote/) and Claude Code
(extensions/claude-code-remote/) are complete implementations.
5 · Ask a sealed decision
A decision card puts a question in the stream and blocks your turn until someone
picks an option. Option ids and tones stay in the clear, because the server
checks an answer against them. The title, the body and the labels travel inside
the ciphertext. sealDecision mints the dreq_ id and
seals against the stream the card is posted to, which on a thread is not the
root the key hangs off.
// BOT_ID is your own bot id, from the bot plane's hello ack.
const card = await sealDecision(
turn.sealing,
{ streamId: cardStream, requesterBotId: BOT_ID },
{
title: "Run the migration now?",
bodyMarkdown: "17 rows backfilled locally, none in prod.",
optionLabels: { run: "Run it", hold: "Hold for review" },
}
)
await api(`/streams/ ${cardStream}/decisions` , {
decisionId: card.decisionId,
options: [
{ id: "run", tone: "primary" },
{ id: "hold", tone: "neutral" },
],
allowNote: true,
runtimeSessionId: SESSION,
invocationId: inv.id,
sealed: { ciphertext: card.ciphertext, envelope: card.envelope },
})
The answer arrives on the /bot namespace as
decision:resolved, or from GET /decisions/:id if you
poll instead. optionId is in the clear.
openSealedDecisionNote opens the note the person typed, and returns
null when that note was sealed to another slot or under a generation this turn
no longer holds. Settle the decision anyway. A rotation between the question and
the answer leaves the answer readable and the note not, and losing the note
beats stranding the turn on it.
socket.on("decision:resolved", async (d) => {
if (d.decisionId !== card.decisionId) return
const note =
d.noteCiphertext && d.noteEnvelope && d.decidedBy
? await openSealedDecisionNote(turn.sealing, {
streamId: cardStream,
decisionId: card.decisionId,
decidedBy: d.decidedBy,
ciphertext: d.noteCiphertext,
envelope: d.noteEnvelope,
})
: null
resume(d.optionId, note) // "run" or "hold", and why
}) 6 · When the grant is taken back
The owner removing your bot from a sealed scratchpad deletes the wraps only your
keys could open, and rolls the stream key forward. You keep what you have
already read and read nothing sent after that. The socket says so with
bot:e2e_revoke ({ workspaceId, botId, streamId }):
call keys.dropStream(streamId) and write presence again. Under the
host policy there is nothing to drop, since that key still opens your other
scratchpads and the roll is what closed this one.
bot:e2e_grant is the same event the other way, when someone invites
the bot into a scratchpad they made, and the hello ack's
e2eGrantedStreamIds catches you up on grants you missed offline.
Re-keying is the owner's, always. Inviting or removing a recipient rolls the generation and re-wraps the new stream key to whoever is left. Your identity key never leaves the machine.
DHKEM(X25519, HKDF-SHA256) with HKDF-SHA256 and
AES-256-GCM. A stream owns one symmetric key per
generation, HPKE-wrapped to each recipient's public key, and every
ciphertext is AES-256-GCM under it with an envelope of
{ v: 2, keyGeneration, iv, aad }. The exact additional-data
strings are short and byte-exact: read them off
buildWrapAad, buildMessageAad and
buildDecisionAad, which the package exports for that reason.