Threads AI

Teams

A lead agent starts members from the agents its team lists, messages them, and wakes up when each one finishes.

Give an agent a team and it becomes a lead. Its model can start members from the agents the team lists, send them messages, ask them questions, wait for them and cancel them. Each member runs in its own thread with its own model and tools. When a member finishes its task, the lead wakes up with the result and carries on, so run() returns the lead's answer after its members have reported.

Everything a team does is recorded in its logs: the lead's thread, one thread per member, and a team log. You can replay, fork and audit a team like any other thread.

A complete example

The lead starts one researcher and answers. When the researcher is done, the lead wakes with its result and gives the final answer. Scripted models stand in for real ones, so this runs offline as a test.

import { agent, scriptedModel, sqlite } from "@threads/core";

const usage = { input_tokens: 10, output_tokens: 2 };
const say = (text: string) => ({
  content: [{ type: "text", text }],
  stop_reason: "end_turn",
  usage,
});
const start = (agentName: string, task: string) => ({
  content: [
    { type: "tool_use", call_id: "c1", name: "start", input: { agent: agentName, task } },
  ],
  stop_reason: "tool_use",
  usage,
});

const researcher = agent({
  name: "researcher",
  instructions: "Research the topic you are given. Answer in one sentence.",
  model: scriptedModel({ responses: [say("Battery pack prices fell this year.")] }),
});

const lead = agent({
  name: "lead",
  instructions: "Start a researcher on the topic, then report what it found.",
  model: scriptedModel({
    responses: [
      start("researcher", "Battery prices this year."),
      say("I started a researcher."),
      say("The researcher reports that battery pack prices fell this year."),
    ],
  }),
  team: [researcher],
});

const r = await lead.run("Report on battery prices.", { store: sqlite(":memory:") });
if (r.status === "completed") console.log(r.output);
// The team comes with every result, whatever the status.
console.log(`team ${r.team.ref.id}`);

It prints the lead's last answer, The researcher reports that battery pack prices fell this year. Its first answer, I started a researcher., stays in the lead's timeline.

The team tools

A lead and its members get these tools. Each call's result is a small JSON object the model reads.

ToolWhat it doesResult
startStarts a member from an agent the team lists, with task as its first input. The member's name is <agent>-<k>, such as researcher-1, counting that agent's members in the team. Only the lead can start members.{"status": "started", "member": {...}}, or {"status": "refused", "code": ...}
sendSends text to a member by name. It arrives as the member's next input, and wakes the member if it is idle. Members can send to each other and to the lead.{"status": "sent", "id": ...}, or {"status": "refused", "code": ...}
askAsks a member a question and waits for its answer. The asker's turn pauses until the member replies, the member ends, the asker is cancelled, or two minutes pass.{"status": "answered", "ask_id": ..., "text": ..., "member": {...}}, {"status": "timed_out", "ask_id": ...}, {"status": "member_ended", ...}, {"status": "cancelled", ...}, or refused
replyAnswers an ask this member received, by its ask_id, with text.{"status": "sent", "id": ...}, or refused (unknown_ask, already_replied, ask_closed)
waitWaits until every listed member has finished its current work (or two minutes pass), then returns what each one did.{"status": "waited", "finished": [...], "parked": [...], "pending": [...], "timed_out": false}, or refused
monitorAsks to be woken when a member ends. A member that already ended returns its result at once.{"status": "monitoring", "member": {...}}, {"status": "ended", "result": {...}}, or refused
cancelCancels a member this agent started. The member stops at its next step, even in the middle of a model call, and ends cancelled; a member still starting ends at once, without its setup.{"status": "cancel_requested", "member": {...}}, or refused (forbidden, member_ended)

A refusal is a value the model can act on, never an error that stops the run. start is refused with unknown_agent for an agent the team doesn't list, concurrency_cap when the team already has its limit of members running, budget_exceeded when a budget covering the new member has no room for one of its model requests, and team_closed once the lead has ended. send is refused with unknown_member, member_ended, self or mailbox_full; ask also with budget_exceeded when the member has no room for one more model request. Only the agent that started a member can cancel it; any other name, including one the team doesn't know, is forbidden.

The results the model reads use snake_case field names, such as ask_id and timed_out. The same results in your own code are typed as AskResult, ReplyResult, WaitResult and MonitorResult (camelCase fields in TypeScript).

A received message reaches the model as untrusted reference text, marked with its sender, never as an instruction.

Waiting for members

The lead starts two researchers and waits for both before it answers. wait returns what each member finished with. Each member's report also wakes the lead once it has finished, so the run's output is the lead's last answer.

import { agent, scriptedModel, sqlite } from "@threads/core";

const usage = { input_tokens: 10, output_tokens: 2 };
const say = (text: string) => ({
  content: [{ type: "text", text }],
  stop_reason: "end_turn",
  usage,
});
const call = (id: string, name: string, input: unknown) => ({
  content: [{ type: "tool_use", call_id: id, name, input }],
  stop_reason: "tool_use",
  usage,
});

const researcher = agent({
  name: "researcher",
  instructions: "Research the topic you are given. Answer in one sentence.",
  model: scriptedModel({
    responses: [
      say("Battery pack prices fell this year."),
      say("Solar module prices fell too."),
    ],
  }),
});

const lead = agent({
  name: "lead",
  instructions: "Start a researcher per topic, wait for both, then summarize.",
  model: scriptedModel({
    responses: [
      call("c1", "start", { agent: "researcher", task: "Battery prices." }),
      call("c2", "start", { agent: "researcher", task: "Solar prices." }),
      call("c3", "wait", { members: ["researcher-1", "researcher-2"] }),
      say("Battery and solar prices both fell this year."),
      say("Their reports are in: battery and solar prices both fell."),
    ],
  }),
  team: [researcher],
});

const r = await lead.run("Compare battery and solar prices.", {
  store: sqlite(":memory:"),
});
if (r.status === "completed") console.log(r.output);

It prints Their reports are in: battery and solar prices both fell.

How a team runs

  • Members work in parallel. Each member has its own thread and runs as soon as it starts. run() runs the members in the same process as the lead.
  • The lead wakes for each member it started. When a member finishes its task, the lead gets its result as a message and takes another turn. run() returns once no lead turn is open and every member this run started has reported. The result is the lead's last answer.
  • A member that ends badly still reports. If a member's model fails, it runs out of budget, or the next run no longer has its definition as it was when it started (the agent was renamed, dropped or changed), it ends failed (or budget_exhausted), and the lead wakes with that result instead.
  • A member that waits parks the lead. If a member stops for an approval, the lead parks on it and run() returns parked, with the member in pending. Answer the approval on the member's thread (the lead's timeline has the member's member_started, with its thread_id), then run the lead again: the member carries on, its result wakes the lead, and then your new input starts the next turn.
  • Asks and waits pause only the asker. While an agent waits on an ask or a wait, its turn is parked and its other work stays where it is; the members keep running. Several asks and waits in one response all pause the turn together, and each answer resumes its own. A member that ends while its own asks are open closes them cancelled, so a later reply is refused ask_closed.
  • A cancel is applied at the member's next step. The member takes it, stopping any model call in flight, closes its own asks and waits, and ends cancelled. A member that is still starting ends at once, without its setup. Its end notifies the lead like any other.
  • A member whose setup keeps failing ends. If a member's setup fails for now (its MCP server can't be reached, say), it is tried again with backoff; after five failures in a row it ends failed with code setup_failed, and the lead wakes with that result.
  • A lead that ends closes its team. If the lead's own turn fails, is cancelled or runs out of budget, the run returns that status at once and the team is closed: no member can be started or resumed, and no message sent. A member already in the middle of a turn finishes that turn.
  • Budgets cover the whole team. A member's model requests, and its own subagents', count against its own budget, the lead's budget, and the budget of the run its current work came from (the run that started it, or the run whose message it is answering). See Budgets.
  • Safe after a crash. Every step is one atomic append. If the process dies, the next run of the lead picks up where the logs left off: a member is started once, gets its task once, and reports once.

Setting it up

const lead = agent({
  model,
  team: [researcher, writer],
  teamLimits: { concurrent: 4, mailbox: 100 },
});
  • team lists the agents the lead may start. Passing it, even as [], makes agent() return a TeamAgent, whose results carry team.
  • The lead's instructions end with the names of the agents its team lists (Agents you can start as team members with start: researcher, writer.), so its model knows what start may name.
  • teamLimits.concurrent caps how many members are starting or running at once (default 4). teamLimits.mailbox caps how many messages can wait for one member (default 100).
  • An agent in a team can't hand off, so a listed agent with handoffs is refused when the lead is set up (handoff_in_team). A lead or member can't have its own tool named start, send, ask, reply, wait, monitor or cancel (duplicate_name). No agent in a team may be named operator, which marks text your own code writes (invalid_config).

Working with the team from your code

A lead's result carries its team: r.team. Your code can start members, message them, ask them questions, wait for them and cancel them, just as the lead's model can. Each call is recorded in the team's log under the principal of the run, so the audit trail shows who asked for what.

const r = await lead.run("Plan the launch.", { store });
const team = r.team;

const started = await team.start("researcher", "Find last quarter's churn numbers.");
if (started.status === "started") {
  const answer = await team.ask(started.member, "Which segment churned most?", {
    timeoutMs: 60_000,
  });
  if (answer.status === "answered") console.log(answer.text);

  const done = await team.wait([started.member]);
  if (done.status === "waited") console.log(done.finished);
}
  • team.ask and team.wait return when the answer is in. Until then your call runs the team for you: members start, work and reply in your process, as they do during the lead's run. An ask ends answered, timed_out (no reply by its deadline, 120 s at most), member_ended or cancelled (the team closed). A wait ends with what settled, what is parked and what is still running; mode: "any" returns once one member settles, and a number returns once that many have.
  • team.cancel returns at once. Its result cancel_requested means the cancel is recorded; the member applies it at its next step and ends cancelled.
  • team.askStatus(askId) (ask_status in Python) reads an ask without waiting: open with its deadline, its outcome, or not_found.
  • Retries are safe with a key. Pass idempotencyKey (idempotency_key): the same key, principal and arguments return the first call's outcome (an ask or a wait still open is joined, not asked again), and nothing new is recorded. The same key with other arguments is refused idempotency_key_reused.
  • Another process, or another person. openTeam(store, r.team.ref, { principal }) (open_team) gives a handle on a team another process ran, acting for that principal. The process must define the same lead agent.
  • Ask members, not the lead. team.ask on the team's lead is refused lead: only members take turns here, so nobody would ever answer it. Sending the lead a message, waiting for it and cancelling it all work as usual.
  • Refusals are values. A call that can't go ahead returns { status: "refused", code }: the member is unknown (a MemberRef of another tenant or team included), has ended or its mailbox is full, the team has closed, or busy if the team's log stayed locked by another writer for 5 s (nothing was recorded; retry with the same key). team.ask and team.wait also return invalid_request for a timeoutMs that is not a positive integer (zero included), and team.wait for an empty member list or a mode that is neither all, any nor a positive integer no greater than the number of members; nothing is recorded.

Following the team's events

team.events() is the team's audit feed: every event of every log in the team, member by member, in the order they committed. Each item carries a cursor, so a reader that stops can pick up exactly where it left off, and follow keeps the stream open for items that have not committed yet.

// A live tail. It ends when the team closes, or when you stop iterating.
let last: TeamCursor | undefined;
for await (const item of team.events({ follow: true })) {
  last = item.cursor;
  // The feed was rebuilt under a new epoch: read on from the cursor it names.
  if (item.kind === "epoch_restarted") continue;
  const who = item.source.kind === "member" ? item.source.member.name : item.source.kind;
  console.log(`${item.cursor.epoch}:${item.cursor.offset} ${who} ${item.event.type}`);
  if (item.event.type === "member_started") break;
}

// Later, or in another process: exactly where that reader stopped.
if (last === undefined) throw new Error("the feed was empty");
for await (const item of team.events({ after: last }))
  console.log(item.kind === "event" ? item.event.type : item.kind);
  • Every item has a cursor, {epoch, offset}. Pass the last one you saw as after and the next read starts just after it: no gap and no duplicate, whether the reader stopped on purpose or was killed.
  • follow: true ends when the team closes, after the items of the append that closed it. Without follow it ends at whatever was committed when the call began.
  • A rebuilt index is a new epoch. Delivery order is an index, and rebuilding it renumbers the offsets, so an old cursor no longer names a position. The stream says so once, with epoch_restarted naming the new epoch, and then replays that epoch from its start. A cursor from a later epoch, or past the end of the current one, is a mistake rather than a restart: it throws InvalidCursorError.
  • It never drives the team. events() only reads: it starts no member and writes nothing. A follower in the same process is woken by each commit; one in another process sees new items within a second.
  • Over HTTP: GET /v1/teams/{team}/events streams the same items as server-sent events, to any principal of the team's tenant. Each message's id is <epoch>:<offset>, so a browser's EventSource resumes with Last-Event-ID by itself; ?after=<epoch>:<offset> does the same by hand, and the header wins when both are sent. The route always follows, and on epoch_restarted it sends that one item and closes, so the client reconnects with the new cursor. A team id outside the caller's tenant is 404, and so is the host team's: it has no HTTP stream.

Specialists the lead defines

Sometimes the lead knows best what help it needs. A dynamic agent lets its model write a specialist's instructions, pick its tools and pick its model when it starts it, while your code still decides everything the specialist may ever have.

import { anthropic } from "@threads/anthropic";
import { agent, dynamicAgent, usd } from "@threads/core";

const specialist = dynamicAgent({
  name: "specialist",
  instructions: "You are a careful analyst. Cite the tool output you rely on.",
  tools: [readNotes, invoiceStatus], // the most a specialist can be given
  models: { fast: anthropic("claude-haiku-4-5"), strong: anthropic("claude-sonnet-5") },
  budget: { max_cost_nanos: usd(0.5) }, // every specialist's own cap
});

const lead = agent({
  name: "lead",
  instructions: "Split the question. Start a specialist for each part, with only the tools it needs.",
  model: anthropic("claude-sonnet-5"),
  team: [specialist],
});

The lead's start call can then add up to four fields, all optional:

FieldWhat the lead choosesWhen it's left out
instructionsText added after the specialist's own instructions, in a block marked as written by the leadOnly the specialist's own instructions
toolsSome of the specialist's tools, by nameAll of them
modelOne of the models keysThe first key
labelA display name for the timeline, such as invoice checker. It works on any start and is never shown to a modelNo label

Your code keeps the rest: permissions, approvals, hooks, skills, memory, the sandbox, the budget and the output schema all come from dynamicAgent, and the lead can't change them. A specialist given tools: ["invoice_status"] has exactly that tool, plus the team and bookkeeping tools every member keeps. It gets no shell and no web access, even when the template has a sandbox and web tools.

  • The lead sees its choices. Its instructions list each dynamic agent with the tools and models it may choose, such as specialist (you write its instructions; tools: invoice_status, read_notes; models: fast (default), strong).
  • A bad choice is refused, with the fix. A tool or model the specialist doesn't have is refused invalid_definition, and the result lists what is allowed, so the model can try again. Written text that tries to close the block or copy the sentence after it is refused too.
  • Budgets use the chosen model. start checks there's room for one request of the model the lead picked, so strong can be refused where fast would start.
  • It's all in the log. The lead's member_started records what it chose (define) and the label, and the specialist's first event records the resulting instructions, tools and model. An approval raised by a specialist shows its name, label and define.
  • The written text is guidance, not a boundary. It helps the specialist do the right job; the tools, permissions and budgets are what limit it.
  • Teams only. A dynamic agent can't be a subagent or a handoff target, and it can't start members of its own.

The subagent task board

Agents with subagents share a simpler task board: team_task_create, team_task_claim, team_task_update and send_message, recorded in the lead's log. It is separate from team and the two don't share anything. See Subagents.

Edit on GitHub

On this page