Alpha · Open source on GitHub

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/threads

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

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.

Every run is a log: thread_started, user_input, model_request, model_response, tool_call, permission_decision, tool_result, turn_completed. Replay (timeline), resume (run again), fork and evals (saveCase) are all read from it.
Replay, resume, fork and evals are not four subsystems. They are four reads of the same events.

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.

await thread.timeline()01a0ddb5-53e0…25 events · append-only

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.

  1. epoch 2

  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.
One trace, the run abovetrace 7037b65c44b8f46e…
  1. the turn · opens at user_input, closes where it parks

    1. invoke_agent supportINTERNAL4.8s
      gen_ai.agent.name=supportthreads.seq.start=2threads.parked
      1. chat claude-sonnet-5CLIENT2.4s
        gen_ai.request.model=claude-sonnet-5gen_ai.usage.input_tokens=1045
      2. execute_tool lookup_orderINTERNAL5ms
        gen_ai.tool.call.id=toolu_01AAnDhh…threads.tool.effect_class=read_only◆ permission_decision
      3. chat claude-sonnet-5CLIENT2.3s
        gen_ai.response.finish_reasons=[tool_use]gen_ai.usage.output_tokens=123
      4. execute_tool mcp__billing__refundINTERNAL4ms
        threads.tool.effect_class=idempotentthreads.parked◆ permission_decision◆ approval_requested
  2. the resumed turn · same trace, linked to the one above

    1. invoke_agent supportINTERNAL1.9s
      gen_ai.conversation.id=01a0ddb5-53e0…threads.seq.start=14◆ approval_granted
      1. execute_tool mcp__billing__refundINTERNAL10ms
        gen_ai.tool.call.id=toolu_01JCYPdB…threads.resumed◆ effect_begin◆ effect_commit
      2. chat claude-sonnet-5CLIENT1.9s
        gen_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);
$ threads eval --agent ./agents.ts
  1. PASSrefund-policy
  2. PASSduplicate-charge
  3. PASSaddress-change
  4. FAILcancel-orderrerun: unmatched tool_call{name: lookup_order}
  5. STALEshipping-etadrift: prompt, tools (+mcp__jira__create_issue)
  6. 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 guide
import { 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);

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.