Threa Developers

    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.

    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.

    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.

    Connect and run
    # 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.

    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 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.

    List open delegations
    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.

    Inspect
    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.

    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. 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.

    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" }'

    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.

    Release
    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.

    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 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.

    A sealed loop, without a line of code
    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.

    Keyring
    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.

    Create and provision
    // 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.

    Open the turn
    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.

    Seal back
    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),
    })
    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. 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.

    A sealed card
    // 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.

    Open the answer
    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.

    Porting it elsewhere If your runtime is not JavaScript, the suite is RFC 9180 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.