Coordinators and sub-agents

Let one agent spawn others, hear from them when it must, ask them anything without stopping them, and let agents outside NoClick talk to yours.

A coordinator is an agent that hands work to other agents and keeps track of it. It spawns a child for each piece of work, and NoClick tells it what happened: when a child finishes, when one is stuck, before one does something that matters. In between, it can ask any of them a question without interrupting them.

Two things decide what a coordinator hears. The parent says when it must hear (updates= when it spawns a child). The child's instructions say what to report, and when to reach out on its own. If you know React, it's a callback prop: the parent hands the child something to call, and each call makes the parent take a turn.

A coordinator

Put "agents" in an agent's tools and it can manage other agents itself:

import noclick

lead = noclick.Agent.from_name(
    "launch-lead",
    create_if_missing=True,
    instructions="You run product launches. Spawn a researcher for each competitor with a budget of $1, "
    "then write the launch brief once every researcher has reported.",
    tools=["search", "fetch", "gmail.send_email_message", "agents"],
    budget_usd_per_month=40,
)
reply = lead.message("Plan the launch of our scheduling app. Competitors: Bookly and Slotwise.", conversation="launch-q4")

It gets six tools:

ToolWhat it does
spawnStarts a child of its own on a task, as spawn does: its instructions, tools, budget, lifetime and the updates the coordinator requires (blocked, before patterns, every_minutes). The child's updates wake the coordinator in the conversation it spawned from.
messageMessages an agent and returns its reply. It waits up to 50 seconds; a reply that takes longer, or a message sent with wait false, is a job of kind agent.message whose done update wakes it in that conversation with the reply. A message to its own child goes to that child's task conversation and carries the work on.
askAsks an agent a question without stopping it, like agent.ask, and returns the answer. One that takes longer than 50 seconds returns its operation's id, and wake_me on that id gives it a turn once the answer is in.
statusAn agent's status, when it was last active, its running turns and children, and the jobs the coordinator started with it; or a job's status, newest update and how it ended.
listAgents, filtered as Agent.list filters them, with mine for its own children.
cancelStops a job it started.

noclick.Agents.only("spawn", "status", "cancel") gives some of them, as connection.only(...) does for a connection's operations; the strings "agents.spawn", "agents.status" and so on mean the same. The model sees them as agents__spawn and so on, and each counts toward an agent's 64 tools. Your policy names them agents.spawn through agents.cancel, so require_approval=["agents.spawn"] or deny=["agents.*"] work as they do for any tool. A refusal, from policy, a limit or a busy conversation, comes back to the coordinator as the tool's result, so it can carry on.

A coordinator works within its own scope. It spawns its own children. It messages, asks and checks the agents it can reach: a user's agent reaches that user's agents and your account's, and an account agent reaches your account's. It cancels only the jobs it started.

Each call acts as the turn it's made in, for that turn's user. When one of your users talks to your account's coordinator, what it spawns and sends runs in that user's threads (its children are still your account's), and it reads and stops only the jobs it started there. Its messages to an agent from one of its conversations share one conversation with that agent, so a follow-up has the earlier exchange; it passes conversation for another. The tools are offered in an agent's own conversations; in a channel, agents work together by mentioning each other.

Spawn a task

Your code spawns the same way a coordinator does. spawn creates a child and starts task as its first turn at once:

lead = noclick.Agent.from_name("launch-lead")
researcher = lead.spawn(
    "Find three recent launches in our category and summarize what worked.",
    instructions="Report what you found with a link for each claim. If a source needs a login, skip it and say so.",
    tools=["search", "fetch"],
    updates=["blocked", noclick.Every(600)],
    conversation="launch-q4",
    budget_usd=1,
    ttl_s=3600,
)
print(researcher.job.status)   # running
reply = researcher.job.wait()  # an AgentReply: what it said last
  • The child lives where its parent does, and runs on the parent's harness, model and model connection unless you pass harness, model or model_connection. Its name is <parent>-<8 characters> unless you pass name. Spawning from your account's agent while acting for a user (user=, or as_user) runs the job in that user's threads; the child is still your account's. A user's own session can't spawn from your account's agent.
  • task is its first message, up to 16,000 characters. instructions are its standing instructions: what its updates should say and when to reach out on its own. NoClick adds a short brief of its own, telling it that it works on one task for its parent.
  • tools must be tools its parent has, or narrower ones (gmail.only("send_email_message") of a parent's gmail); "card" gives it the card its parent pays with. A tool its parent lacks raises noclick.InvalidRequest naming it, so a child never gets more than its parent. Without tools, it has none of its parent's, and it never gets its parent's can_request. Like every agent it has its shell (none when its parent has shell=False, which carries down) and wake_me, and as a spawned child it has tell_parent. A person among its parent's tools passes down only with tools the parent has of them, and a bound operation only as the parent binds it.
  • budget_usd is carved from the parent's budget (see Budgets), and ttl_s ends it.

researcher.job is the task: a noclick.Job, an operation of kind agent.task. It runs while the child works on it, across as many turns as that takes, and finishes when the child's conversation goes quiet with nothing left to continue it: no turn running, no wake-up pending, nothing waiting on a person.

job.statusMeans
runningIt's working (step is working), or it set itself a wake-up and waits for it (step is waiting).
awaiting_humanIt waits on a person (step is blocked): an approval for a call policy held, or an account it asked its user to connect.
succeededIt stopped. wait() returns its last reply as an AgentReply.
failedA turn failed; wait() raises its error.
canceledjob.cancel() stopped it (or deleting the child): its running turn stops and its pending wake-ups are canceled.
expiredIts ttl_s ran out first.

A turn of the job that fails fails the job with that turn's error.

A child that needs a decision says so in its reply and stops, so its job succeeds with the question in it. The answer is a new message to the child in the same conversation, researcher.message("Use the 2025 numbers.", conversation=researcher.job.conversation), which is an ordinary turn. A coordinator's message tool sends its answer there too, as a job of its own whose reply wakes it.

A child waiting on a person carries on by itself once the person answers: a held call wakes its conversation when it's approved or declined, and a connection it asked for wakes it when the link is done (connected, or expired), so its job goes back to running.

To make a child without starting anything, as before, create it with a parent: noclick.Agent.create("helper", instructions="...", parent_id=lead). Its budget carves from the parent's in the same way, and it waits for a message.

What the child reports, and when

updates= lists when NoClick makes the child report. It never says what the report contains: that's for the child's instructions or the task.

UpdateWhen
"done"The job finished, however it ended. Always on, listed or not. It carries the child's last reply, or the error.
"blocked"The job started waiting on a person: a held call, or a connection it asked its user for. It says what it waits on (waiting_on); for a connection, with the page where the person connects it. A held call's page goes only to its approver, so an agent never approves its child's call.
noclick.Before("gmail.send_email_message", "stripe.*")Before a matching tool call runs. It carries the call and what the child wrote with it: its reason.
noclick.Every(900)While the job works, at most this many seconds (or a timedelta) apart, 60 at least.
writer = lead.spawn(
    "Write the launch email for the beta list and send it.",
    instructions="Before you send anything, say who it goes to and quote the subject line. "
    "Tell your parent at once if the beta list has fewer than 500 people.",
    tools=["gmail.send_email_message"],
    updates=["blocked", noclick.Before("gmail.send_email_message")],
    conversation="launch-q4",
)

Before patterns are written as in your policy. The call runs right after its update: the update doesn't wait for the parent. A call that must wait for someone's yes is a require_approval rule; a call that policy refuses or holds sends no Before update. A coding harness's own shell commands and file edits are commands in its sandbox, not tool calls NoClick sees, so Before can't match them, and its words reach NoClick only when its turn ends, so its Before update has the call and no reason.

For Every, NoClick asks the child for an update once that long has passed since its last one: on a coding harness by steering its running turn, on sdk before its next model call. The child answers with tell_parent, and that message is the every update. A child that's only waiting, on a wake-up or a person, isn't asked.

Between required updates, a child reaches out on its own with tell_parent(message), a tool every spawned child has: at most one a minute and 30 an hour. Its instructions say when ("Tell your parent at once if...").

Updates wake the parent

Every update wakes the parent: a wake-up of source update in the parent's conversation that started the job, whose turn reads the update. From your code, conversation= names that conversation; a coordinator's spawn tool uses the conversation it spawned from. Updates that arrive while the parent is mid-turn wait for it, and arrive together in its next turn.

Without conversation=, updates from a child your code spawned wake nobody. They wait on the job, and every update, woken or not, goes to your webhooks as agent.job.update:

job = researcher.job
for update in job.updates():
    print(update.kind, update.created_at, update.text)

Each JobUpdate has its kind (done, blocked, before, every, or message for tell_parent), the child's text, the tool and arguments of a before update, what a blocked one is waiting_on (kind connection or approval), the result or error of done, and the parent's wakeup_id it set. job.latest_update is the newest one.

Ask without stopping it

ask gets an answer from an agent without stopping what it's doing:

answer = researcher.ask("Which launches you've read so far had a waitlist?")
print(answer.text)

By default the agent's model answers from a snapshot: its instructions and the conversation as it stands, including what the running turn has done so far. It answers your question alone, with no tools. The question and answer aren't added to the conversation, the agent itself never sees them, and its running turn carries on untouched. Each ask is recorded in its activity as an agent.ask operation, billed like a short turn. conversation= picks which conversation it's about; without one, it's a spawned child's task, else the conversation the agent last worked in for the user you act for.

On a coding harness, the snapshot is what NoClick holds: the conversation up to its last finished turn, plus the NoClick tools the running turn has called, and its model answers on its connection's key. The harness's own shell commands and file edits arrive only when its turn ends, so "what are you doing right now?" is better asked with interject=True. An agent that thinks with a Claude or ChatGPT plan raises noclick.NotAvailable: a plan runs only inside its harness, and answering the snapshot in a one-off harness session is in invite-only beta. interject=True works for it.

With interject=True, the question joins the running turn. On a coding harness it arrives through the harness's own steering, the way a person types into a session that's working; on sdk it arrives before the turn's next model call. The agent sees it, may change course, and answers in that turn, and ask returns the reply the turn ends with. The question stays in the conversation. With no turn running, it's an ordinary message.

reply = researcher.ask("Skip anything published before 2025.", interject=True)

Keep track

lead = noclick.Agent.from_name("launch-lead")
for child in lead.children():
    print(child.name, child.status, child.running_turns, child.last_active_at)

children() lists every child of the agent, newest first, expired ones too for the 30 days they're kept. Each has a status (working, idle or expired), the other listing fields in Find your agents, and, if spawn made it, its job; noclick.Agent.list(parent=lead, status="working") filters them on the server.

for job in lead.jobs(status="live"):
    print(job.agent_id, job.status, job.step, job.conversation)

jobs() lists the jobs an agent started: the tasks of the children it spawned (kind task) and the messages it sent with its message tool (kind message, whose only update is done, carrying the reply; a message it waited for has nothing to wake, so its parent_conversation is None). Filter by status ("live" is every status that isn't final), kind, agent (the agent doing the work) and conversation (the parent's conversation that started them). Your account reads every user's jobs of an account agent; a user reads its own. A job is an operation, so job.wait(), job.cancel() and noclick.Operation.from_id(job.id) work as they do for any other, and noclick.Job.list() reads spawned tasks across your agents.

Limits

  • A child never gets a tool its parent doesn't have. The agents tools pass down only when the parent has them and lists them for the child: a parent with "agents" may give "agents.spawn", and a parent with only "agents.spawn" can't give "agents".
  • A coordinator has at most 20 messages waiting for replies; past that, message tells it to wait.
  • An agent has at most 20 live children. Children spawn their own up to 3 levels below an agent with no parent. Past either, spawn raises noclick.QuotaExceeded.
  • tell_parent sends at most one update a minute and 30 an hour; past that, the child is told to wait.
  • Every turn a task takes, an update wakes or an ask answers is billed like any other turn, to the user the agents belong to, and counts toward their budgets and your spend caps.

Budgets

An agent's budget_usd_per_month caps what it can spend in a calendar month. A child's budget comes out of what its parent has left, and everything its children spend counts toward the parent. When an agent (or its family) reaches its budget, its next turn fails with noclick.SpendCapExceeded.

noclick.Usage.limits() shows each budgeted agent's spend this month, with subtree_spent covering its children.

Let other agents talk to yours

Every agent speaks A2A, the open agent-to-agent protocol. Other agents discover it by its agent card and message it over JSON-RPC:

planner = noclick.Agent.from_name("planner")
card = planner.a2a_card()
print(planner.a2a_url)   # the JSON-RPC endpoint: message/send and tasks/get

Callers authenticate with one of your platform keys as a bearer token, so give each outside caller its own narrowly scoped key.

Talk to agents outside NoClick

To bring an outside agent into a conversation with yours, add its A2A endpoint to a channel:

planner = noclick.Agent.from_name("planner")
room = noclick.Channel.create("launch", members=[planner, noclick.A2AMember("https://agents.partner.com/a2a")])

Every method is in the reference: Agent.spawn, Agent.ask, Agent.children, Agent.jobs, Job, JobUpdate, Before, Every and Agents.