Agents

Create a hosted agent in one call, give it your users' connections, and message it.

A hosted agent is a model with instructions and tools that NoClick runs for you. You message it, it works (calling tools, acting in your user's accounts), and it replies. Conversation history, tool execution, retries and billing are handled on NoClick's side.

Create an agent

import noclick

with noclick.as_user("user_42"):
    gmail = noclick.Connection.from_name("gmail", integration="gmail", create_if_missing=True)
    stripe = noclick.Connection.from_name("stripe", integration="stripe", create_if_missing=True)

    support = noclick.Agent.from_name(
        "support",
        create_if_missing=True,
        instructions="You answer the user's billing questions. Look invoices up before answering.",
        tools=[gmail, stripe.only("list_invoices", "search_charges"), "search"],
        budget_usd_per_month=50,
    )

from_name finds the agent by name and, with create_if_missing=True, creates it on first use. noclick.Agent.create always creates, and raises noclick.Conflict if the name is taken. Names are unique for each user, and for your account.

  • instructions is the system prompt, up to 32,000 characters.
  • harness is what runs it: NoClick's own agent loop (sdk, the default) or a coding agent such as Claude Code. See Harnesses.
  • model is the harness's own model. On sdk it's an OpenRouter model id with an openrouter/ prefix, like openrouter/anthropic/claude-sonnet-4.5. Leave it out for the harness's default.
  • tools are connections (a Connection, or its name: every operation), some operations of one (stripe.only("list_invoices"), or the string "stripe.list_invoices"), operations that need no account ("exa.search"), the capabilities "search" and "fetch", a toolkit, or NoClick itself ("noclick", see Give it NoClick itself). A lazy create_if_missing connection is created before the agent.
  • budget_usd_per_month caps what the agent may spend in a month.
  • ttl_s makes it temporary: that many seconds after it's created it expires, taking no more turns, and it's deleted 30 days later. See Find your agents.
  • can_request lets it ask the user for more connections as it goes: see Asking for connections.

A name in tools is a connection in the agent's scope. When no connection has that name, it's the integration of that name, limited to its operations that need no account. So "exa.search" gives any agent Exa's web search on NoClick's key, a user's agent included, with nothing to create or connect. "exa" gives it every Exa operation that needs no account. A connection named exa holding your own key takes over the same names: see Operations on NoClick's key.

A connection must exist when the agent names an operation that needs an account. Without one, creating the agent raises noclick.InvalidRequest, which tells you to create it. A lazy Connection in tools, like stripe.only(...) above, is created first; a name in a string is not.

An agent holds at most 64 tools. A small integration fits whole (Gmail has 28 operations), but a large one doesn't: Stripe alone has more than 400, so name the operations the agent needs.

Every agent can also run shell commands in a sandbox of its own, whether or not you list code. To take that away, deny code in your policy, or create the agent with shell=False: see What it can reach.

Harnesses

harness= picks what runs the agent. The default, sdk, is NoClick's own agent loop. The others are coding agents, each running in a sandbox of its own with its own shell and files, thinking with a model provider's key or plan that you hold in a connection: the agent's model_connection.

harness=What runs itmodel=model_connection
sdkNoClick's agent loopAn openrouter/ id; with a connection, anthropic/... or openai/... on your keyOptional
claude_codeClaude CodeAn alias (sonnet, opus, haiku) or a claude-... idanthropic
codexOpenAI's CodexA Codex model, like gpt-5.6-luna or gpt-5-codexopenai
opencodeOpenCodeprovider/model, like opencode/big-pickleThe model's provider
hermesHermes Agentprovider/model, a model that calls toolsThe model's provider
openclawOpenClawprovider/modelThe model's provider

noclick.Agent.harnesses() lists them with the version NoClick runs, each one's default model and the providers whose connection it can think with. A harness's models() searches the models it offers, by id or name; leave model out for the harness's default.

opencode = noclick.Agent.harness_info("opencode")
print([m.id for m in opencode.models(search="claude")])   # ['opencode/claude-haiku-4-5', ...]

noclick.Agent.harness_info(name) is one of them by name; an unknown name raises noclick.NotFound, naming the ones there are.

An agent on Claude Code, with the Anthropic connection it thinks with:

claude = noclick.Connection.from_name("claude", integration="anthropic", create_if_missing=True)
coder = noclick.Agent.from_name(
    "coder",
    create_if_missing=True,
    harness="claude_code",
    model="sonnet",
    model_connection=claude,
    instructions="You maintain our docs repo.",
    tools=["github", "search"],
)
if not claude.connected:
    print(claude.connect_url)   # where you paste an Anthropic key
reply = coder.message("Fix the broken links in README.md", conversation="docs-1")

Every harness but sdk needs a model_connection, a connection in the agent's own scope. It may still be pending when you create the agent: until someone connects it, the agent's turns fail with noclick.NeedsConnection, which carries the link (a user messaging an account agent gets noclick.NotAvailable, with no link). Inside as_user, the agent and its connection are the user's, and the user can connect their own Claude or ChatGPT plan instead of a key: a Claude plan runs only claude_code, a ChatGPT plan codex, opencode and hermes (model providers). An account agent thinks with your account's connection, which takes a key only.

What changes on a harness other than sdk:

  • Its sandbox starts first. A conversation's first turn starts the sandbox, which takes longer than a turn on sdk, and it stays warm for about a minute after each reply. While it starts, the turn's operation has step "delivering", then "working".
  • The reply arrives whole. The harness doesn't stream its reply as it's written; the whole text comes when the turn ends.
  • Instructions set a conversation's start. A conversation takes the agent's instructions at its first turn, so new instructions apply to conversations that start after the change.
  • It has its own shell, file editing and web. Beside the tools you give it, it runs the harness's built-in commands inside its sandbox. The reply's tool_calls list them with source: "harness". Your policy judges the tools you give it, not these.
  • Its sandbox time is billed. The model's tokens go to the connection's key or plan; NoClick bills the sandbox's running time: see Spend.
  • It doesn't take phone calls yet. A phone number answers with an sdk agent.

Changing harness moves each conversation to the new harness at its next turn, history included. A new harness without a model takes that harness's default model.

Your account's key for a user's agent

A user's agent can think with your account's model key instead of one of its own: pass your account's Connection object as model_connection, as you would to share a connection. The same rule applies: only a key acting for the whole account may set it, and it must hold a key, since a person's Claude or ChatGPT plan is never shared.

claude = noclick.Connection.from_name("claude", integration="anthropic", create_if_missing=True)   # your account's key
with noclick.as_user("user_42"):
    coder = noclick.Agent.from_name(
        "coder",
        create_if_missing=True,
        harness="claude_code",
        model_connection=claude,
        instructions="You maintain the user's docs site.",
    )

Where it runs

region= picks the region its sandboxes run in, for the agent's whole life: a coding harness runs there whole, and an sdk agent runs its shell there while its agent loop runs in NoClick's own service. Without it, a child takes its parent's region, else your account's default, else NoClick runs it wherever it has capacity (region is None). Put it beside its data with region=db.region.

reviewer = noclick.Agent.from_name(
    "reviewer",
    create_if_missing=True,
    instructions="You review pull requests for our EU customers.",
    harness="codex",
    model_connection="openai",
    region="eu",
)

An agent that should always be up, and answer the moment a message arrives, can run on a machine instead: machine= puts its harness on an always-on machine at a flat monthly price, with no sandbox per conversation.

Your code is the definition

With create_if_missing=True, from_name also keeps an existing agent in step with your code: when its instructions, harness, model, model_connection, tools, can_request or budget differ from what you pass, it's updated to match on first use. Deploy new instructions and every user's agent picks them up the next time your code touches it. Connections a user granted the agent at run time aren't part of the definition, so a deploy never removes them. A field you leave out is never changed, and None clears model_connection or the budget: moving an agent back to sdk is harness="sdk", model_connection=None. An agent's region never changes: asking for another raises noclick.Conflict.

Message it

reply = support.message("Why was I charged twice in March?")
print(reply.text)

message sends the text and waits for the reply, an noclick.AgentReply with the reply text, the conversation it belongs to, and the tool_calls it made, each {tool, arguments, result, is_error, created_at} as history() shows them. A message is at most 16,000 characters. A user's agent always acts for that user, wherever you call it from.

To keep talking in the same thread, pass the conversation back:

follow_up = support.message("Can you refund one of them?", conversation=reply.conversation)

Without a conversation, a message starts a new one. You can also choose the key yourself, for example your own ticket id: any 1 to 128 letters, digits and . _ : @ + -. A channel's conversation (channel:<its id>) is the one key you can't use: only the channel starts turns in it, under its stop rules, posting their replies to the room, so message and wake refuse it with noclick.InvalidRequest. Post in the channel instead.

Email, calls and your owner's texts can land in a conversation you name too, so one person reaches the agent in one thread whichever way they get in touch: conversation= on an inbox, on a number's answer_with, and on Human.owner().answer_with.

Send it files

files sends documents, images, audio and video with a message, at most 10:

analyst = noclick.Agent.from_name("analyst")
reply = analyst.message("What stands out this quarter?", files=["q3-report.pdf", "chart.png"])

A path is uploaded first. To send bytes, a file object or a recorded voice clip, upload it and send the attachment:

analyst = noclick.Agent.from_name("analyst")
clip = analyst.upload(recording, name="question.webm", voice=True)
reply = analyst.message("Here's my question.", files=[clip])

The turn reads each file the way the agent reads one from any channel: a document's text, a voice clip's transcript (credit-gated, as reading media always is), an image as an image. Files are kept as your account's files, capped as those are (10 MB an image, 15 MB a document, 20 MB audio or video), and an upload is sent once, to the agent it was uploaded to. history() shows each message's files, and a stream shows them as file events. Over HTTP, POST /v1/agents/{id}/attachments takes the file's bytes, and the message sends its id in attachments.

Once sent, a file is one of your account's files, beside the images and videos you made and your calls' recordings. noclick.attachments() lists them, newest first (query matches their names), and a message from the account sends any of them again:

analyst = noclick.Agent.from_name("analyst")
[report] = noclick.attachments(query="q3")
reply = analyst.message("What changed since this report?", files=[report])

They're the account's alone: a user's key can't list them or send them. Over HTTP, GET /v1/attachments lists them, and a message sends their ids in attachments as it does an upload's.

Don't wait on the reply

send starts the turn and returns an operation at once, which suits web servers and job queues:

turn = support.send("Summarize this customer's open tickets")
save(turn.id)

reply = noclick.Operation.from_id(turn.id).wait()   # later, anywhere: an AgentReply

A conversation takes one turn at a time. A second message to a conversation that's still answering another message waits a few seconds, then fails with noclick.Conflict. Behind a turn the agent took on its own (a wake-up's or a trigger's), a message waits for it and runs next, before the agent's next wake-up: its operation is running at step waiting until then.

Wake it up later

A wake-up gives one conversation a turn later: at a time, after a while, or when something finishes. The turn's message is your note, followed by what woke it.

support = noclick.Agent.from_name("support")
support.wake("ticket-1841", after=3600, note="Check whether the refund has landed, then tell the customer.", user="user_42")

finance = noclick.Human(email="finance@acme.com").approve("Refund $95.00 to Ana Lima?")
support.wake("ticket-1841", when=finance, note="Finance answered about the refund. Tell the customer what they decided.",
             user="user_42")

Give it exactly one of:

  • at: a datetime with a timezone, from a minute to a year ahead.
  • after: seconds, or a timedelta.
  • when: an operation (a person's answer, a connect link, a turn your code started with send, a purchase): it wakes the conversation once the operation finishes, with its outcome. One that has already finished wakes it at once.
  • every: a noclick.Cron, for a routine: it wakes the conversation at each of the schedule's times, until you cancel it (below).

when can also be an event type (* matches the rest of a name: "connection.*"), with filters naming fields its data must have: it wakes the conversation the first time a matching event happens after you set it (its turn starts within about half a minute, as any due wake-up's does), and the turn reads the event. A user's conversation hears that user's events; one of your account's own conversations hears every event, your users' included.

with noclick.as_user("user_42"):
    assistant = noclick.Agent.from_name("assistant")
    assistant.wake("trip-planning", when="connection.connected", filters={"name": "calendar"},
                   note="The user connected their calendar. Book the flights now.")

A routine is a wake-up that comes back. every takes a Cron, read in its tz; tz="user" is the timezone of the user whose conversation it is, and follows them when they move. Unlike a schedule trigger, which starts a fresh conversation each time, a routine wakes the same conversation, so each turn reads what the last one did:

coach = noclick.Agent.from_name("coach")
standup = coach.wake("standup", every=noclick.Cron("0 9 * * 1-5", tz="user"),
                     note="Ask how yesterday went and what's blocking today.", user="user_42")

standup.update(every=noclick.Cron("30 9 * * 1-5", tz="user"))   # half an hour later from now on
standup.cancel()

A routine stays pending after each turn, its at the next time and fired counting the turns it started. Times it missed, while the conversation was busy or a turn couldn't run, fold into one turn rather than several. update changes a pending wake-up's note or its time (at, after or every); a turn about to start for what it said before doesn't start. A wake-up that's been delivered, canceled or failed raises noclick.Conflict.

wake returns a noclick.Wakeup, and wakeups() lists them. user= (or as_user) says whose thread the conversation is, as it does for message, and user_filter=noclick.ALL_USERS lists a shared agent's wake-ups across its users' threads, each with its user_id, as conversations does:

support = noclick.Agent.from_name("support")
for wakeup in support.wakeups(conversation="ticket-1841", user="user_42"):
    print(wakeup.source, wakeup.status, wakeup.at, wakeup.note)
    if wakeup.status == "pending":
        wakeup.cancel()

for wakeup in support.wakeups(status="pending", user_filter=noclick.ALL_USERS):
    print(wakeup.user_id, wakeup.conversation, wakeup.at)   # cancel() acts in its own thread

A wake-up's status is pending, delivered (its turn started: operation_id is that turn), canceled or failed (error says why). Its source says who set it: code for agent.wake in your code, wake_me for the agent itself.

Every agent has a wake_me(note, at, after_minutes, operation_id, every) tool, so it can come back to something on its own: "check again in an hour", "carry on when this job finishes", or "every Monday at 8, send me the week's numbers". One set on an operation_id also wakes it each time that operation stops to wait on a person (a build asking a question), with what it asks, so the agent can say what's needed; it still wakes when the operation ends. every is a cron expression read in the agent's timezone, else its user's, else UTC; at without an offset is UTC. With it come list_wakeups, the wake-ups pending in the conversation, and cancel_wakeup, and wake_me with a wakeup_id changes one, so the agent keeps its own routines when the user asks it to. It changes and cancels only the ones your code or it set, never NoClick's. Your policy names all three wake_me, so deny=["wake_me"] keeps an agent from setting its own wake-ups. In a channel the channel decides who takes a turn, so an agent has no wake_me there.

How a wake-up is delivered:

  • On time, give or take half a minute. A wake-up that comes due starts its turn within about 30 seconds.
  • Behind a running turn. A conversation takes one turn at a time, so a wake-up that comes due while a turn runs waits for it, then runs. It never fails with Conflict.
  • Once, and durably. A wake-up survives restarts and deploys, and starts exactly one turn.
  • Billed like any turn. Its turn counts toward the agent's budget, its user's cap and your account's funds. A one-off wake-up whose turn your account can't pay for fails instead of running; a routine misses that time and comes round at its next, its missed saying when and why (credits, spend_cap or budget).

An agent holds up to 100 pending wake-ups, 20 of them routines; one more raises noclick.QuotaExceeded. One conversation's wake-ups start at least a minute apart, and a routine comes round at most once a minute (once every 15 minutes for one the agent sets itself, since each time is a billed turn); a schedule that comes round more often raises noclick.InvalidRequest. An agent that keeps waking itself, 10 wake_me wake-ups in a row with no message or other turn in between, is refused the next one and told to stop or wait for someone. Deleting an agent cancels its pending wake-ups.

NoClick wakes an agent itself too, listed beside yours with their own source: update (a child it spawned, reporting back: see Coordinators), answer (a person it asked something answered, or didn't in time, or the link it asked its user to connect an account with is done, connected or expired: see Grants) and approval (a person decided a call your policy held: see When a call waits for approval). None of them is set in a channel's conversation, whose turns the channel starts.

Your webhooks hear agent.wakeup.delivered (its turn started), agent.wakeup.missed (a routine's time your account couldn't pay for, skipped) and agent.wakeup.failed (it couldn't run and won't come again: your account couldn't pay for a one-off, or the agent was gone).

Whose accounts it acts in

An agent's connections belong to the same place the agent does. An agent created inside as_user belongs to that user and uses that user's connections. An agent created outside as_user belongs to your account and uses your account's connections: a support agent with your company's help desk, say. Those include the accounts you connected in the NoClick app, by their id ("credential:<id>" or "credential:<id>.operation"): see Accounts connected in the app.

Every user can message an account agent, each in conversations of their own, but its tools always act in your account's accounts, never the user's. Look it up outside as_user and pass user= to message: inside as_user, Agent.from_name finds only the user's own agent by that name, never your account's. A user talking to it never gets a link to connect one of your account's connections: an unconnected one just tells the user that tool isn't available.

To act in each user's own accounts, give each user an agent of their own. It's the same code, run inside as_user:

def assistant_for(user_id: str) -> noclick.Agent:
    with noclick.as_user(noclick.User.from_id(user_id, create_if_missing=True)):
        gmail = noclick.Connection.from_name("gmail", integration="gmail", create_if_missing=True)
        return noclick.Agent.from_name(
            "assistant",
            create_if_missing=True,
            instructions="You help the user with their email. Be brief.",
            tools=[gmail],
        )

When a connection the agent uses has no account yet, its tools that need one answer with the connection's link and the agent passes it on in its reply; its operations on NoClick's key already work. The next message after the user connects works normally.

To add a connection from another scope beside its own, such as a company's shared Slack or your account's support mailbox, pass that Connection object in tools (invite-only beta): see Share one across users.

Read conversations

for convo in support.conversations(limit=10):
    print(convo.conversation, convo.title, convo.turns, convo.cost_usd)

for message in support.history(reply.conversation):
    print(message.role, message.text)
    for call in message.tool_calls:
        print("  ", call["tool"], call["arguments"], call["is_error"])

Each message keeps the files it carried (a reply, the files its turn made and sent you) and, a reply, the tool_calls its turn made, each {tool, arguments, result, is_error, created_at} as the turn's tool_call and tool_result events showed it (a result is its preview, an error's is the error; arguments cut short are {"_truncated": true}), and the reasoning lines its model sent, each {text, created_at}, so a chat UI draws a reloaded thread the way it drew the live one.

A user message that didn't come through the API says how it came in via: whatsapp or imessage (a text to NoClick's number), call (said on a phone call) or email (mail to one of its inboxes). It keeps what the person wrote; what the agent was told about answering in that app reached the model only. Its reply goes back the same way, so a chat UI can caption both.

A user message no person sent says what started its turn in trigger: {kind, id, title}, where kind is the trigger's (webhook, event, cron, platform or queue) or wakeup for a wake-up, id is the trigger's or the wake-up's, and title is what fired (an event's type, a wake-up's note). A NoClick event's (platform) also has its event, {id, type, data}. Its text is what the agent read (the trigger's message and the event), so a chat UI can show the turn by what happened (say, "A run of Daily digest failed", from the event's data) and its reply as the agent's own update, rather than as something the user said.

Add a note to a conversation

note adds a line to a conversation without starting a turn: something your app wants the thread to show and the agent to know, like a plan change or a record the user just picked. It shows in history() and watch as a note message with its data, and the agent reads both when its next turn starts, once. A key adds it once, so a retry never adds it twice:

support = noclick.Agent.from_name("support")
added = support.note("ticket-1841", "Ada upgraded to the Pro plan.", data={"plan": "pro"}, key="ticket-1841:pro")

note returns False when a note with that key was already there. A note is up to 4,000 characters, its data up to 4 KB of JSON. Inside as_user it goes to that user's conversation, as a message does. Over HTTP, POST /v1/agents/{id}/conversations/{conversation}/notes takes {text, data, key}.

A conversation's status is active, awaiting_human while a turn waits on a person (a question it asked, a call your policy held, a sign-in its browser needs), or handed_off while a person has it: see Give an agent a person. To follow one as it moves, whoever moves it, see Follow a conversation live.

Watch what it did

Every turn is an operation. operation.tool_calls() lists the tools the turn called, with their arguments, results and timings, and support.console_url opens the agent in the developer console, with each conversation and turn.

Find your agents

Every agent says what it's doing: its status, when it was last_active_at (None if it never ran), how many running_turns it has, and its child_count, the live agents made under it with parent_id (or spawned). Agent.list filters on them. Inside as_user, an account agent's are None (its activity is other users' turns too), and the filters below never match it on them:

from datetime import datetime, timedelta, timezone

for agent in noclick.Agent.list(status="working", order="last_active"):
    print(agent.name, agent.running_turns, agent.last_active_at)

week = datetime.now(timezone.utc) - timedelta(days=7)
quiet_coders = noclick.Agent.list(status="idle", active_since=week, harness="claude_code")
leftovers = noclick.Agent.list(temporary=True, status="expired")
statusMeans
workingA turn is running, queued or waiting on a person (running_turns says how many).
idleNothing is running.
expiredIt was temporary and its ttl_s ran out. It takes no turns, and its pending wake-ups are canceled, but you can still read it, its conversations and its jobs for 30 days, when it's deleted.

A temporary agent used to be deleted the moment its ttl_s ran out. It now stays expired for those 30 days first, so you can still read what it did. Messaging or changing it raises noclick.Expired, and delete() removes it before then. Its name is free as soon as it expires: from_name with create_if_missing=True makes a new agent of that name rather than finding the expired one, and Agent.list(name=...) lists both.

paused isn't a status. Pausing stops an agent's triggers, not its work: a paused agent can be working on a message your code sent. Read agent.paused for that, and agent.trigger_kinds for what wakes it, without a read per agent ("cron" in agent.trigger_kinds is an automation).

The filters are status, active_since (active at or after a time), parent (an agent's children; agent.children() is the same), temporary (made with ttl_s, or not) and harness. order="last_active" puts the most recently active first and those that never ran last; the default is the newest first. They're cheap on NoClick's side: last_active_at is stamped as turns start and end, running turns and live children are counted from indexes, and status follows from those, so a filtered listing costs no more than a plain one.

Agents built in the app

The agents in your workflows, built in the NoClick app, are your account's agents too. Agent.list gives them after the ones made through the API, with origin app (the others say api; origin= lists only one kind), and you message them the same way:

for agent in noclick.Agent.list(origin="app"):
    print(agent.name, agent.workflow_name, agent.enabled, agent.app_url)

triage = noclick.Agent.list(origin="app", name="Triage")[0]
reply = triage.message("Anything urgent in the queue this morning?")
print(reply.text)

An app agent's id is node:<workflow id>:<node id>, and it carries its workflow_id, node_id, workflow_name, whether it's enabled, your access to its workflow (owner, or editor or viewer for one shared with you) and its app_url. A message runs a turn on the agent's own step, as a message from the app does, as an operation (send) or waited on (message), with files; conversation= continues a thread. A reply arrives whole, so stream() is refused. What the agent is (its instructions, model, tools, triggers) is changed in the app, where its conversations are read too: beyond reading it and messaging it, the API refuses it with a link to its workflow, and only name and harness filter the list down to it. A user's scope (as_user) never sees them, since they belong to the account, and only a workflow you own or edit takes messages. When one messages you with its message_coordinator tool, an agent.asked_owner event carries it.

Tell it the time

Every turn starts by telling the agent the current date and time, so "last month", "this Friday" and "yesterday's tickets" mean what they say. The time is in the agent's timezone (an IANA name) when it has one, else the acting user's timezone, else UTC. It opens the turn's message as a line like "It is Thursday, 1 October 2026, 09:15 in Europe/London (UTC+01:00).", on every harness and every kind of turn (a message, a trigger, a wake-up, a channel or a call), and stays out of the conversation you read back.

digest = noclick.Agent.from_name(
    "digest",
    create_if_missing=True,
    instructions="You summarize last week's support tickets every Monday.",
    timezone="Europe/London",
)

A schedule can follow the user too: noclick.Cron("0 8 * * *", tz="user") fires at 8:00 wherever the agent's user is (Triggers).

Give it a repository

repos= clones repositories into the agent's workspace, /workspace/<name>, at a conversation's first turn, so a coding agent starts every conversation with the code in front of it:

github = noclick.Connection.from_name("github", integration="github", create_if_missing=True)
fixer = noclick.Agent.from_name(
    "fixer",
    create_if_missing=True,
    harness="claude_code",
    model_connection="claude",
    instructions="You fix failing tests in acme/web. Push your fix to a branch named agent/<issue> and open a pull request.",
    tools=[github.only("create_pull_request")],
    repos=[github.checkout("acme/web", push=["agent/*"])],
)

connection.checkout(repository, branch=, path=, push=) takes a repository a github connection of the agent's own scope reaches; an agent takes up to 10, and none without a shell (shell=False). path names the folder (the repository's name by default) and branch the branch it starts on (its default branch). The connection may still be pending when you define the agent; a turn that would clone through it first raises noclick.NeedsConnection with its connect_url. Checking out one of your own repos with repo.checkout(...) is in invite-only beta.

The agent's git reaches those repositories only, through NoClick, with a key made for its sandbox and revoked when the sandbox ends; the connection's own token never enters the sandbox, so the model can't read it. push lists the branches it may push to, as globs; without it the checkout is read-only. NoClick judges every push on its way to GitHub and refuses one that touches any other ref (a tag, or main unless a glob names it), in git's own words (! [remote rejected] main -> main (NoClick: this checkout may push only to agent/*)). Whatever the globs allow, the agent can't force-push or delete the repository's default branch, and before a push rewrites or deletes any other branch NoClick keeps its old head in the repository for 30 days, as refs/noclick/backups/<branch>/<time> (Branches and history). This sits in git itself because a coding harness's own git push is a command in its shell, not a tool call your policy could judge.

gh in the shell reads the same repositories through NoClick, and the agent is told how, and nothing more, since its writes could merge or move a branch past push. Opening a pull request or commenting is the connection's operation, github.only("create_pull_request", ...) in tools, which your policy judges like any other.

The clone lives in the conversation's workspace, so the next turn finds it as it was left, with whatever wasn't pushed yet; a changed repos reaches a conversation at its next turn, and a clone already in its workspace stays as it is.

What it can reach

shell=False takes the shell away from an sdk agent altogether: it keeps only the tools you list, code among them is refused (noclick.InvalidRequest), and the children it spawns have none either. A coding harness refuses it with noclick.InvalidRequest, since the shell is the harness. update(shell=True) gives it back.

faq = noclick.Agent.from_name(
    "faq", create_if_missing=True, instructions="You answer from our help center.", tools=["search"], shell=False,
)

network says where the agent's shell and code may connect: True (the default, anywhere), False, or a list of up to 50 hosts, with *.example.com for a domain and every name under it. Hosts are reached over HTTPS on port 443 only, as in a sandbox's list.

analyst = noclick.Agent.from_name(
    "analyst",
    create_if_missing=True,
    instructions="You answer questions about our orders from the data in /data.",
    network=["pypi.org", "files.pythonhosted.org"],
)

What the agent needs to work stays reachable, and False leaves only that:

AgentAlways reachable
sdkNoClick's git proxy, when it has repos. Its model and its tools run on NoClick, not in its shell, so with no repos False is no network at all.
A coding harnessNoClick's API (its turns, and its tools, which it reaches there over MCP), NoClick's proxy for git and for its model on an Anthropic, OpenAI or OpenRouter key, and on a plan its provider's own hosts: api.anthropic.com and platform.claude.com for a Claude plan, chatgpt.com and auth.openai.com for a ChatGPT plan.

network limits its shell and code, not its tools: fetch still reads any page, so leave it out of tools to keep the agent off the web. On a limited network a coding harness on a key always reaches its model through NoClick's proxy, so the provider's API isn't open to its code; a key the proxy doesn't carry (anything but Anthropic, OpenAI or OpenRouter) can't run on one yet. Where NoClick can't keep to a limited network, creating or updating the agent raises noclick.NotAvailable rather than widen it.

A change reaches the agent's next turn; a coding harness's sandbox restarts for it, as for any change to what it starts with. A child takes its parent's network unless it's created with its own, and the children an agent spawns always do. A reachable host is reachable with anyone's account, NoClick's own API included: network bounds where code connects, not as whom.

See what its model used

An agent that thinks with your own key or plan (a model_connection) doesn't bill its model calls to you, so NoClick meters them for you instead. reply.model_usage holds the turn's input_tokens (cached_tokens of them read from the provider's cache), output_tokens and what they'd cost at the provider's list price in usd; convo.model_usage sums a conversation's turns, and a streamed turn's done event carries it too. It's None for an agent on NoClick's key, whose turns are billed as usual (cost_usd).

Where the numbers come from: an sdk agent's calls, as the provider reports them; a coding harness's calls, one by one where NoClick's model proxy carries them, else what the harness reports at the end of its turn (a Claude or ChatGPT plan, say), priced as if it ran on a key. Usage recorded after a turn settles isn't in that turn's model_usage, but is in the conversation's.

reply = coder.message("Fix the broken links in README.md", conversation="docs-1")
print(reply.model_usage.input_tokens, reply.model_usage.usd)

Give it NoClick itself

"noclick" in tools gives an agent your account's API as tools: what a connected app gets on the MCP server, search, fetch, api_describe and api_call, plus quote, buy and delete. It can create agents and resources, buy a phone number or a domain, deploy and read what happened, as your code would:

ops = noclick.Agent.from_name(
    "ops",
    create_if_missing=True,
    instructions="You run our NoClick account: numbers, domains, agents and apps.",
    tools=["noclick"],
)
reply = ops.message("Get us a phone number in the 415 area code.")

Its calls act as the turn: the key that sent the message and the user it acts for, never more than that key may. The rules that judge a connected app judge them:

  • Deleting something the trash keeps (an app, database, bucket, dict, queue, repo, volume, inbox or agent) moves it there at once, restorable for 30 days. A purchase, and a deletion that can't be undone (purging the trash, or deleting anything the trash doesn't keep), wait for your account's owner, who approves it on NoClick with its exact price, or with what goes with it. The turn waits on them as on any person it asked, and the call's outcome (run once approved, or declined) is the conversation's next turn. The model never approves anything itself.
  • A secret's value never comes from the conversation: the agent creates the secret by name, and a person types its value on NoClick (Secrets). Keys, policy and anything that reveals a secret stay with a person.
  • A turn answering someone outside the agent's scope (an email to its inbox, a caller) asks a person before anything that acts outside your account.
  • A long action, like a deploy, answers with its operation, and the agent calls wake_me with its id to take a turn when it ends.

noclick.Platform.only(...) narrows it. Capability groups (read, build, deploy, spend, delete) bound what kind of call it makes; API areas (a key's scopes, like domains or phone) bound which part of the API it reaches:

registrar = noclick.Agent.from_name(
    "registrar",
    create_if_missing=True,
    instructions="You look after our domains and their DNS.",
    tools=[noclick.Platform.only("domains")],
)

Without spend it isn't offered quote and buy, and without delete not delete. Your policy names its tools noclick.search ... noclick.delete, so a rule can hold or forbid any of them. In a stream or a watch, a held purchase or deletion is an approval event and a long action an operation event (Sharing and streaming). It works in the agent's own conversations; in a channel the room starts its turns, and it isn't offered there.

When a call waits for approval

When your policy holds a tool call for a person's approval, the turn tells the user it's waiting and the conversation carries on. Once the person decides, the conversation takes a turn on its own (a wake-up of source approval) with what came of it: approved, NoClick runs exactly the call that was held (an operation's, once) and the turn gets its result; declined, the turn has the decline and the approver's note, and the call never runs. A held shell command or other tool that isn't an operation the agent makes again itself, as the turn tells it. Its reply goes out wherever the conversation lives: the chat, or the email thread. A held call in a channel's conversation wakes nothing, since the channel starts its turns. agent.triggers() lists an approval trigger while your policy may hold one of its tools, and request.approve(note=...) answers one from your own code.

Change or remove it

support.update(instructions="...", tools=["stripe.list_invoices", "stripe.create_refund", gmail, "search"])
support.delete()

Omitted fields stay as they are. Changes apply from the next turn.

Triggers, memory, sharing and test runs

Wake an agent on a webhook or an app event, a Slack message or a failed payment, on a schedule as an automation, or for each message on a queue, without anyone messaging it: Triggers and automations. Give its shell your API keys, secrets= on create and from_name: Secrets. Rehearse it against a simulated world before it touches real accounts: Test runs. Give it a memory it keeps across conversations, read the checkpoint a long conversation keeps there, and put files in a conversation's workspace for it: Memory and files. Let it read memories other agents read too, memories=[company] on create and from_name: Share a memory between agents. Give its shell a folder every conversation shares, volumes={"/data": vol} on create and from_name: Volumes. Keep its files in a bucket, a folder per user: Storage. Share it at a public link, or stream its turns into your own UI and follow a conversation live: Sharing and streaming. Let it notify or ask a person, or hand them the conversation, a Human in tools: Give an agent a person.

Next