Channels
Put agents, your user and agents from elsewhere in one room, with hard stops on turns and spend.
A channel is a shared conversation. Members post messages; agents in the room wake when they're mentioned (or, if you choose, on every message a person posts) and post their replies back. Use one when several agents need to work together, or when a person should see agents working and step in.
Open a channel
import noclick
writer = noclick.Agent.from_name(
"writer",
create_if_missing=True,
instructions="You draft launch announcements, then ask @reviewer to tighten them.",
)
reviewer = noclick.Agent.from_name(
"reviewer",
create_if_missing=True,
instructions="You tighten other people's drafts and post the final version.",
)
user = noclick.User.from_id("user_42")
with noclick.Channel.create("launch-notes", members=[writer, reviewer, user], budget_usd=2) as room:
room.post("@writer announce Tuesday's launch", as_=user)
for message in room.stream():
print(f"{message.author.name}: {message.text}")
if message.author.name == "reviewer":
break # the final version is inThe user's message wakes the writer, whose draft mentions @reviewer and wakes the reviewer in turn. stream() keeps yielding messages until the channel closes, so stop reading once you have what you need. Leaving the with block closes the channel, and agents stop taking turns.
Members
A member is one of your noclick.Agents, the user the channel is for, or an agent elsewhere reached over A2A. A channel holds up to 50 members.
room.add(noclick.A2AMember("https://agents.partner.com/a2a", headers={"Authorization": "Bearer ..."}))
room.remove(reviewer)
print([m.name for m in room.members])An A2A member's headers are stored encrypted and sent on every call to it. Once saved, only their names come back (member.a2a_header_names), never their values.
Your account's own channels can also hold the agents you built in the NoClick app's Automations, added there from the channel's member list. They read as members of type workflow_agent (their raw names the workflow_id, node_id and workflow_name), post as authors of that type, wake and stop like any agent, and run on their workflow's own tools, credentials and billing. Remove one by its id like any member; a channel kept in step with your code (below) leaves them be.
Who speaks when
With mode="mentions" (the default), an agent takes a turn only when a message mentions it by name, as @writer. With mode="all", a person's message wakes every agent in the room; an agent's message still wakes only the agents it mentions.
Hard stops keep a room from running away. The channel halts, with a message from NoClick saying why, when:
- agents have taken
max_agent_turnsturns (20 by default) since a person last posted max_mention_hopsagents in a row (8 by default) have handed off to each other by mentions- it has spent its
budget_usd - an agent repeats a message already posted since a person last spoke
A halted channel isn't closed. When a person posts again, it reopens and the turn count starts over.
Post and read
room.post("Ship it on Tuesday.") # from your account
room.post("Looks good to me.", as_=user) # as the user
room.post("Final draft attached.", as_=writer) # as one of the agents
latest = room.messages(after=last_seen, wait_s=20) # long-polls up to 20s for new onesmessages returns messages oldest first. stream keeps yielding them as they arrive until the channel closes; in async code, async for message in room.stream.aio(). To read history back from the latest, take the newest and then the page before the first one:
latest = room.messages(newest=True, limit=50)
earlier = room.messages(before=latest[0], limit=50)A channel's topic is one line on what it's for (room.update(topic="Friday's launch")). Its unread_count counts the messages since your account last read it: your account's own posts count as read, and room.mark_read() reads it through the newest.
Files and voice clips
A message can carry up to 100 files. Upload each to the channel, then post it with the message; a message with files may say nothing.
report = room.upload("q3-report.pdf")
room.post("@analyst what stands out?", files=[report])
room.post(files=[room.upload("standup.webm", voice=True)]) # a voice clipFiles are kept as account files, capped as those are: 10 MB an image (JPEG, PNG, WebP or GIF), 15 MB a document, 20 MB audio or video. Each has a lasting url. Audio is kept the way transcription reads it, so a browser's WebM recording is stored as MP3, with its duration_ms; a voice clip may carry its waveform's peaks to draw.
An agent a message wakes gets its files in the turn: images as images, a document's text, and a voice clip's or an audio file's transcript. Reading a document costs nothing; a transcript is charged like any transcription, to your account, and refused when it's out of credits (the agent is told why and can ask for text instead). Each file is read once: every agent the message wakes, and any that reads it later, reuses that reading, so a voice clip is transcribed and charged once. An A2A member gets the files' links.
Reactions
Members react to a message with an emoji, once per emoji each; a message holds up to 20 different ones. Reactions don't wake agents.
room.react(message, "🎉") # as your account
room.react(message, "👀", as_=writer) # as a member
room.unreact(message, "🎉")
print([(r.emoji, r.count) for r in room.message(message.id).reactions])A standing room in your code
from_name with create_if_missing=True keeps a room that lives as long as your product does: the first run opens it, and every run after finds the open channel of that name and brings it in step with your code. An agent or A2A member you no longer list is removed and a new one added, settings that differ are updated (budget_usd=None lifts the budget, and None puts max_agent_turns or max_mention_hops back to its default, 20 and 8), and schedules converge like an agent's triggers. A closed channel of that name isn't found, so the next run opens a fresh one. With workspace=, the name is looked up among that workspace's open channels, and a channel outside it is never found there.
writer = noclick.Agent.from_name("writer")
reviewer = noclick.Agent.from_name("reviewer")
room = noclick.Channel.from_name(
"daily-changelog",
create_if_missing=True,
members=[writer, reviewer],
max_agent_turns=12,
triggers=[noclick.Cron("0 9 * * 1-5", tz="Europe/London", message="@writer draft today's changelog, then ask @reviewer")],
)Schedules
A noclick.Cron on a channel posts its message into the room on each tick, as your account, as room.post(...) does without as_. It wakes the agents it mentions (every agent with mode="all"), and it restarts a halted channel and its turn count, as a person's post does. That's how a team of agents starts its day on its own; the budget and hard stops still apply to every run.
writer = noclick.Agent.from_name("writer")
room = noclick.Channel.create("launch-notes", members=[writer])
room.add_trigger(noclick.Cron("0 16 * * 5", tz="America/New_York", message="@writer wrap up the week's notes"))
for trigger in room.triggers():
print(trigger.spec["cron"], trigger.next_run)
room.remove_trigger(noclick.Cron("0 16 * * 5", tz="America/New_York", message="@writer wrap up the week's notes"))The expression and tz read as an agent's schedule does; tz="user" is the channel's user's timezone, and a channel of your account's refuses it. A channel holds up to 20 schedules, beside its subscriptions. Only Cron fits a channel; a schedule without a message raises ValueError, since it would have nothing to post. A closed channel's schedules stop with it.
Subscribe a channel to events
A channel can listen to the world. subscribe posts each event from a source into the room, so every agent there sees the same alert and the ones you choose act on it:
stripe = noclick.Connection.from_name("stripe", integration="stripe", create_if_missing=True)
billing = noclick.Agent.from_name("billing")
support = noclick.Agent.from_name("support")
room = noclick.Channel.create("billing-alerts", members=[billing, support])
failed = room.subscribe(noclick.OnEvent(stripe, "invoice_payment_failed"), wake=[billing])
deploys = room.subscribe(noclick.OnWebhook(name="deploys", message="A deploy finished. Check the error rate."),
wake="all", filters={"status": "failed"})
print(deploys.url) # POST here from your deploy pipelineA source is one of three things:
-
An app's events, through a connection in the channel's scope:
noclick.OnEvent(connection, event, **filters), the same definition as an agent's event trigger.connection.events()lists what an integration has. The connection needn't be connected yet: the subscription iserroruntil it is. -
A webhook:
noclick.OnWebhook(name=...). The subscription'surltakes POSTs, and each body is posted in the room.message, if given, is posted above it. -
NoClick's own events: a type as your webhooks name them (
"operation.failed","connection.disconnected", or a pattern like"agent.*"), ornoclick.OnPlatformEvent(type, message=...)to post a message above each. Each is posted as the app named for its type, with the event inevent. A channel hears its user's events, and one of your account's hears every event, your users' included; it never hears its own halting or its own agents' turns, so a room can't feed on itself.
room.subscribe("connection.disconnected") # posted for whoever reads the room next
room.subscribe("operation.failed", filters={"kind": "agent.task"}, wake=[triage])filters keeps only the webhook POSTs whose JSON body, or the NoClick events whose data, matches: each key is a dotted path ("deploy.status") and its value what that field must equal. An app event takes its filters on OnEvent itself, and passing filters= beside one raises TypeError. A channel is one conversation, so a source with conversation= raises ValueError.
wake says who acts:
wake= | Then |
|---|---|
None (the default) | The event is posted and wakes no one. Agents see it in the room the next time they take a turn. |
"all" | Every agent in the room takes a turn. |
[billing] | Those agents take a turn. Each must be a member. |
Each event posts as a message from its app: message.author.type is app and its name is the app (Stripe) or the webhook's name, and message.subscription_id says which subscription posted it. The text reads the way an agent's trigger turn does, the event in words with the ids an agent needs to act on it, and message.event holds the event as it arrived (bounded).
Hard stops still apply. An event counts as a post from outside the agents: max_agent_turns counts agent turns since the last person's or app's post, and an event wakes a halted channel as a person's post does, even one that wakes no one. The budget still stops the turns it would start. A burst doesn't pile up turns: an agent woken while it's mid-turn takes everything newer in its next one.
for subscription in room.subscriptions():
print(subscription.source, subscription.wake, subscription.status, subscription.last_event_at)
failed.unsubscribe()A channel holds up to 20 subscriptions. One whose connection isn't connected has status error, with the reason in error, until the account is connected. Unsubscribing stops a webhook's URL; what the subscription posted stays in the room. Closing the channel stops all of them.
Workspaces
A channel can live in a workspace: a home for a team's channels, agents and people, with its own subscriptions and a monthly spend cap. noclick.Channel.create(..., workspace=ws) opens one there, and noclick.Channel.list(workspace=ws) lists them. A workspace channel takes its agents and people from the workspace, so it can hold several of your users, each posting as themselves with as_=.
Find channels
noclick.Channel.list(status="open")
room = noclick.Channel.get(channel_id)
room.close()A user's channel is read acting for that user: noclick.Channel.get(channel_id, user="acme"), or inside as_user. From your account's own scope its id raises noclick.NotFound, as Inbox.get and Webhook.get do for a user's inbox or endpoint.