Agents you can inspect, replay and trust.
Build in TypeScript or Python on an append-only event log. Inspect model calls, tool effects and approvals; use built-in sandboxes, channels, memory and evals.
git clone https://github.com/useagenthq/threadsNot on npm or PyPI yet. Install from source.
- TypeScript and Python
- one log, both languages
- No service to sign up for
- it runs in your process
- SQLite or Postgres
- your database, your data
- Apache-2.0
- open source
Works with the models you use
Models, retries and fallbacks →Anthropic
TypeScript · Python
OpenAI
TypeScript · Python
AI SDK
Any AI SDK provider · TypeScript
LiteLLM
OpenAI-compatible endpoints · Python
The problem
Stop rebuilding the same agent plumbing
Sandboxes, approvals, resume-after-crash, audit and evals get built again on every agent project, each one its own little system. threads writes one append-only log instead and reads all of them back out of it.
Every line on the right is a real event type or function. Follow any row into the guide that documents it.
- What an agent ends up needingWhat you already have
- Carry on after a crash without paying the refund twiceeffect_begin → effect_commit
- Hold a risky call until a person decides, hours later, in another processapproval_requested → approval_granted
- Show the exact bytes the model was sent, months after the answer went outmodel_request.request_ref
- Give the agent a machine of its own, with no internet and none of your keyssandbox: e2b()
- Turn a real conversation into a test that reruns with no model callssaveCase() → threads eval
- Open a run as a trace in the tracing tool you already pay forotel()
The idea
state = reduce(log)
Every run is an append-only log of what the agent saw and did. Nothing is kept beside it, so nothing can drift from it.
01The record
Every run reads back, step by step
There is nothing to instrument. A thread already holds every input, the exact bytes of every model request, every tool call, every approval and every result. timeline() reads it back in order, from production, months later, with only the store and the thread id.
From the command line the same run is threads timeline <thread_id>. Pick any entry below to see the stored event.
Up to the park
Asked over the API. The agent looks up the order, asks to refund it, and the policy hands the decision to a person.
After the approval
The approval arrives under a new epoch, the run picks up where it parked, the refund goes out, and a second turn follows.
epoch 2
epoch 3
parked · entry 13 of 25, read from the recording
- actor
- {"kind":"host"}
- data.address
- {"kind":"approval","id":"f3ec2389-269c-4b1d-a126-50dc3e854b5c"}
- data.reason
- "awaiting_approval"
- data.expires_at
- 1790429648227
Every value on this page comes from components/home/recorded-run.json, the timeline() output of one real run, committed in this repo. Values over 116 characters are cut with an ellipsis; nothing else is changed.
timeline()
Read a bad answer back, months later, with no logging added.
run()
Resume after a crash without sending the refund twice.
fork()
Branch from an eligible snapshot and try another prompt from the same state.
saveCase()
Keep a turn as a test that reruns with no model calls.
This is one recorded run of a support agent on claude-sonnet-5, not a drawing of one. It parked waiting for a person to approve a refund, and the epoch in the margin is the lease changing hands: whether the approval takes a millisecond or a week, the log reads the same.
02Observability
The same log, as OpenTelemetry spans
One line on your host sends every turn, model call and tool call to the tracing tool you already run. Spans are computed from the log rather than recorded beside it, so a run that crashed and was recovered still traces, and an approval a person gave hours later lands in the same trace as the turn that asked for it.
The panel on the right is the recorded run above put through the rules in spec/otel/README.md: the turn span closes where the run parks, the resumed turn opens on the approval, and the span ids are derived from the event ids.
import { host } from "@threads/host";
import { otel } from "@threads/otel";
// OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io
// OTEL_EXPORTER_OTLP_HEADERS=x-honeycomb-team=<your key>
// OTEL_SERVICE_NAME=support-bot
export default host({
store,
agents: { support },
telemetry: otel(),
});The exporter speaks OTLP/HTTP JSON and the standard OTEL_* variables, so the endpoint is the only thing that changes:
- Honeycomb
- Datadog
- Langfuse
- Grafana
- Maple
- any OTel collector
- Content is off by default
- Names, timings, token counts and outcomes leave the process. The conversation does not, and prompts are never exported.
- Sent once, when a span closes
- A slow collector never holds up a run, and nothing is dropped while it is down.
- Nothing extra is recorded
- A pure function turns committed events into spans, so a run that crashed and was recovered traces exactly like one that did not.
the turn · opens at user_input, closes where it parks
- invoke_agent supportINTERNAL4.8sgen_ai.agent.name=supportthreads.seq.start=2threads.parked
- chat claude-sonnet-5CLIENT2.4sgen_ai.request.model=claude-sonnet-5gen_ai.usage.input_tokens=1045
- execute_tool lookup_orderINTERNAL5msgen_ai.tool.call.id=toolu_01AAnDhh…threads.tool.effect_class=read_only◆ permission_decision
- chat claude-sonnet-5CLIENT2.3sgen_ai.response.finish_reasons=[tool_use]gen_ai.usage.output_tokens=123
- execute_tool mcp__billing__refundINTERNAL4msthreads.tool.effect_class=idempotentthreads.parked◆ permission_decision◆ approval_requested
the resumed turn · same trace, linked to the one above
- invoke_agent supportINTERNAL1.9sgen_ai.conversation.id=01a0ddb5-53e0…threads.seq.start=14◆ approval_granted
- execute_tool mcp__billing__refundINTERNAL10msgen_ai.tool.call.id=toolu_01JCYPdB…threads.resumed◆ effect_begin◆ effect_commit
- chat claude-sonnet-5CLIENT1.9sgen_ai.response.finish_reasons=[end_turn]gen_ai.usage.output_tokens=77
Every span carries threads.thread_id, threads.branch_id and the log positions it covers, so a span in your tracing tool points straight back at the events that produced it. The second question the customer asked opens its own trace: a user_input is always its own root.
03Evals
Save a real run. Rerun it forever.
saveCase keeps a turn you liked as a regression case: the recorded replies, the tool results and the exact request bytes. threads eval then checks it on every commit.
A saved case reruns with zero model calls, and an effectful tool call can only answer from the recording, so an eval never performs a real side effect.
// Once, from a real thread you liked.
await thread.saveCase("refund-policy", {
expect: { must: [{ type: "turn_completed" }] },
externalEffects: "stub",
});
// In CI: no model calls, no API keys, no network.
const report = await runEvals({
cases: "cases",
agents: [support],
});
console.log(report.summary);- PASSrefund-policy
- PASSduplicate-charge
- PASSaddress-change
- FAILcancel-orderrerun: unmatched tool_call{name: lookup_order}
- STALEshipping-etadrift: prompt, tools (+mcp__jira__create_issue)
- SKIPhandoff-to-engoffline_not_runnable:child_threads
3 passed, 1 failed, 1 stale, 1 skipped
- replayno model calls
- Every recorded request in the case re-renders byte for byte under the threads code running now.
- rerunno model calls
- The saved turn still runs to the same events: the same replies, the same tool results, every expectation matched.
- driftno model calls
- The case was recorded with the agent config you have now — the same instructions, tools and model.
- judgewith --live
- Your current agent answers the case's input, and a judge model grades the whole turn against your rubric.
Use cases
Built for the agent you need
The same agent definition runs as a script, in a sandbox, in Slack or on a schedule. Pick one to see the whole program.
Nothing above changes when you switch: the run is the same log whether it started from a script, a Slack message or a cron trigger.
A name, instructions and a model, and that is the program. The run writes its log to .threads, which is the thread timeline() reads back.
Read the guideimport { agent } from "@threads/core";
import { anthropic } from "@threads/anthropic";
const support = agent({
name: "support",
instructions: "Answer questions about our product. Be brief.",
model: anthropic("claude-sonnet-5"),
});
const result = await support.run("Can I return a keyboard?");
if (result.status === "completed") console.log(result.output);Built in
What you can build
Each piece is one option on your agent or host. Bring your API keys; threads does the plumbing.
Every piece has the same shape in TypeScript and Python, and the public names are mapped one to one in spec/api.json.
- Evals & testingSave a real run as a regression case and replay it with a scripted model: no API keys, no network.saveCaseScripted modelFake sandbox
- SandboxesRun code in an isolated machine with no internet by default. Your keys never enter it.E2BDaytonaModal · Python
- Agents & toolsA model, instructions and tools. Built-in shell, file, web, git and code tools, plus any MCP server.agent()tool()MCP
- Multi-agentLet an agent start helpers, hand the conversation to a specialist, or share a task board.SubagentsHandoffsTeams
- ChannelsPut an agent in Slack, WhatsApp or GitHub, on a cron schedule, or behind an HTTP API.SlackWhatsAppGitHubHTTP
- Memory & knowledgeRemember across runs and search your own documents, scoped per tenant.LocalSupermemoryZep
- DurabilityResume after a crash without repeating an action. Park risky steps for a human to decide.Crash-safeApprovalsBudgets
- Hooks & permissionsGate tools, inject context, and decide who can approve what.HooksRulesPlan mode
- API referenceEvery public function and type, side by side in both languages.TypeScriptPythonHTTP
Run your first agent with no API key
The quickstart builds a weather agent on a scripted model, so it runs offline with no keys. Then you swap in a real one and read back the thread you just produced.