Sharing and streaming
Share an agent at a public link, stream its turn live into your own UI, and follow a conversation as it moves.
Three ways to put an agent in front of people: a share link, a public chat page NoClick hosts for it; a stream, the turn arriving piece by piece so your own UI can show the reply as it's written and each tool as it runs; and watch, which follows a conversation whoever moves it.
Share an agent at a link
import noclick
support = noclick.Agent.from_name("support")
link = support.share()
print(link.url) # https://noclick.com/a/...share turns the agent's chat page on and returns its link, or returns the link it already has. Anyone with the URL can talk to the agent, no sign-in: each visitor in conversations of their own, which show up in the agent's conversations() like any other (their keys start share:). The page wears your brand: its name, logo, accent color and support link; without one it's NoClick's.
Visitors act in the agent's own scope: an account agent's page uses your account's connections, a user's agent's page that user's. So share an agent whose tools are safe in a stranger's hands, like a support agent that searches your docs, and let your policy hold anything that isn't. A visitor is someone from outside: the agent never hands them a link to connect an account (a tool whose connection isn't connected tells them it can't do that right now, and the connection's own person is emailed its link, at most once a day), and it reads its memory without saving to it.
Only the agent's owner shares it: your account for an account agent, the user (or your account) for a user's own agent. A user's key gets noclick.PermissionDenied sharing an account agent.
Turn it off or change the link
support = noclick.Agent.from_name("support")
link = support.share()
link.disable() # the page says the agent isn't available
link.rotate() # a new URL; the old one stops at onceshare() turns a disabled page back on at the same URL, and share_link() reads the link as it is without turning it on (None if the agent was never shared). created_at is when it was first shared and rotated_at when its URL last changed. Rotate when a link has gone somewhere it shouldn't: the old URL stops at once, a disabled link stays disabled, and conversations on the old link stay readable in the agent's history (a visitor who comes back through the new link starts a new one).
To build the chat into your own site instead of linking to NoClick's page, mint a user session in your server and call the agent from your page with the TypeScript client: agent.send for each message, agent.history and agent.watch for the conversation. For a shared agent anyone may talk to, your page can call its chat directly instead, with a publishable key and no server of yours in between.
Chat from your own page
A shared agent's chat is a public endpoint: create a publishable key that lists it, with your site as an allowed origin, and the page sends each visitor's messages itself. Its turns are the share page's own: in the agent's scope, answering someone from outside, billed to your account and counted against the agent's budget, your caps and the key's cap.
const KEY = "nk_pub_..."; // safe in the page: it calls only what it lists
const AGENT = "7b1e4f2a-...";
const visitor = localStorage.getItem("visitor") ?? crypto.randomUUID();
localStorage.setItem("visitor", visitor);
async function ask(text) {
const url = `https://api.noclick.com/v1/agents/${AGENT}/share/messages`;
const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
let reply = await (await fetch(url, { method: "POST", headers, body: JSON.stringify({ text, visitor }) })).json();
while (reply.status === "running") { // a turn past 25 seconds: read it again
reply = await (await fetch(`${url}/${reply.operation_id}`, { headers })).json();
}
return reply.status === "succeeded" ? reply.text : reply.error.message;
}visitor is an id your page keeps for each visitor, 16 to 40 letters, digits, - or _ (a crypto.randomUUID() will do): their conversations are theirs alone, and anyone who knows it can continue them, so make it random. conversation (default main, up to 32 of the same characters) picks one of the visitor's conversations; a new one starts fresh. Each conversation shows up in the agent's conversations() like the share page's.
A message waits up to 25 seconds for the reply. A longer turn answers 202 with status: "running" and its operation_id; GET .../share/messages/{operation_id} waits up to another 25 seconds (wait_s) and answers the same way. A visitor reads only the reply's text, never the turn's tool calls, and only the turns their key started. A turn that fails answers status: "failed" with a short error; one a cap or your balance stops answers 409 not_available, so the visitor never reads why; a user's agent whose spending you paused answers 402 spending_paused with only the message and button you wrote for that user; and once the agent's page is off the key's calls are refused (403) until you turn it on again.
Stream a turn
stream sends a message like message does and yields the turn as it happens:
support = noclick.Agent.from_name("support")
for event in support.stream("Where's my refund?", conversation="ticket-1841"):
if event.type == "text":
print(event.text, end="", flush=True)
elif event.type == "tool_call":
print(f"\n[{event.tool}]")
elif event.type == "done":
print("\n", event.reply.conversation)Each AgentEvent has a type:
type | Carries |
|---|---|
text | text: the next piece of the reply. |
reasoning | text: one short line of what the model is thinking (the title of a section of its reasoning, or its first sentence), sent as it completes, with its created_at. Models that reason without saying so send none. |
status | status: compacting while a long conversation's older history is being summarized (it can take a minute), then compacted. |
tool_call | tool (its wire name, like stripe__list_invoices) and its arguments, as the agent calls it; held when your policy holds it for a person: who the approver is and, when the request is addressed to the scope you stream as (your account's approvals on an account key, a user's in that user's session), its request_id and page url. |
tool_result | tool, its result (the start of what it returned, up to 500 characters, or its error) and whether it is_error. |
file | file: a file the message carried (role user, sent with files) or the turn made (assistant), with its name, url and mime_type. |
approval | approval: a purchase or a deletion its NoClick tools made waits for your account's owner. Its id, summary, exact price (a purchase), expires_at and, streamed as your account, the url of the page where the owner approves. |
operation | operation: a long action its NoClick tools started, with its id (an operation's), kind, status, summary, and when it started and last changed (created_at, updated_at). It comes again each time the work reports progress, with that progress (its last 3 lines), and once more when it ends. |
done | reply: the whole AgentReply, as message returns it, with its tool_calls and model_usage. Always last. |
It's the same turn as message: same conversation rules, same billing, and it's an operation you can read afterwards (event.reply.operation_id; every event's operation_id names it). A turn that fails raises its error from the loop, like noclick.SpendCapExceeded; one refused up front, like a conversation still busy with a turn, raises before the first event.
An agent on a coding harness writes its reply whole, so it arrives as one text event just before done; the calls it makes to NoClick's tools stream as they happen, and its own shell and file steps are in the reply's tool_calls.
Relay it to a browser
In an async web server, iterate stream.aio and pass each event on as it comes:
support = noclick.Agent.from_name("support")
async for event in support.stream.aio("Where's my refund?", user="user_42"):
await websocket.send_json({"type": event.type, "text": event.text, "tool": event.tool})If the connection to NoClick drops mid-turn, the loop raises noclick.APIConnectionError, but the turn itself runs on: read its reply with history(), or wait on its operation.
Over HTTP, the stream is POST /v1/agents/{id}/messages?stream=1 with Accept: text/event-stream: server-sent events named text, reasoning, status, tool_call, tool_result, file, approval, operation and done, an error event if the turn fails, and a comment line every 15 seconds to keep the connection open (see the HTTP API).
Follow a conversation live
stream shows a turn you started. A conversation also moves without you: a turn resumed after an approval or a connection it asked for, a trigger firing into it, a message from another tab or session, a person replying after a hand-off. watch follows all of it:
support = noclick.Agent.from_name("support")
for event in support.watch("ticket-1841", user="user_42"):
if event.type == "message":
print(event.role, event.text) # user, assistant, person or note
elif event.type == "tool_call" and event.held:
print("Waiting for approval:", event.held["url"])
elif event.type == "status":
print("Now", event.status) # awaiting_human or activeIt yields the same events as stream (text, reasoning, tool_call, tool_result, file, approval, operation, and status while a long thread is compacted), plus message for each whole message once the conversation keeps it, with its role, its files, how it arrived (via) or, one no person sent, what started its turn (trigger) and, a reply, its tool_calls and reasoning, or a note's data (as history() reads it), and status when the conversation starts waiting on a person (awaiting_human: a held call's approval, a connection it asked for) or is back to active. Your own turns come through too: each event's operation_id names its turn, so skip the ones you started (a message no turn sent, like a person's reply, has none). It starts from now, so read what came before with history(), and runs until you stop reading or the conversation is deleted. It costs nothing.
If the connection drops, the loop raises noclick.APIConnectionError; call watch again, and read history() for any message you missed meanwhile.
Over HTTP it's GET /v1/agents/{id}/conversations/{key}/events with Accept: text/event-stream (user_id names whose thread, like the conversation read).
Limits and billing
- A shared page's turns bill to you and count toward the agent's budget and your spend caps. When one of them is spent, or your balance can't pay, the visitor reads that the agent isn't available; nothing about money ever reaches them.
- Each visitor sends at most 6 messages a minute, and one at a time per conversation (a second while the agent answers reads "still working on the previous message").
- A stream holds one connection for the length of the turn, up to the turn's own limit; a watch holds one for as long as you read.
Errors
| Error | When |
|---|---|
noclick.Conflict | The conversation is still answering a turn. |
noclick.SpendCapExceeded | The turn would pass the agent's budget or a spend cap. |
noclick.PaymentRequired | Your account can't pay for the turn. |
noclick.PermissionDenied | A user's key shares an account agent. |
noclick.NotFound | Rotating the link of an agent that was never shared. |
noclick.APIConnectionError | The stream broke; the turn may still finish. |
In the reference: Agent.share, ShareLink, Agent.stream, Agent.watch and AgentEvent.