Cotal
DocsAPI Reference
Agents

Agent terminal control

Managed seat control uses the running manager's native endpoint commands.

Managed seats

cotal ps [--json | --plain] [--on <instance>] [--wide] [--slots] [--space <s>]
cotal stop <name> [--force] [--on <instance>] [--space <s>]
cotal stop --self [--force]
cotal attach [<name>] [--on <instance>] [--no-reconnect] [--space <s>]
FlagDefaultMeaning
--space <s> / --server <url> / --creds <path>resolved meshWhich manager to reach
<name> / --name <n>noneManaged agent to stop or attach, named positionally or with --name. stop needs one unless --self is given. attach without one lists your agents to choose from on a terminal, and refuses USAGE_INVALID where it cannot ask
--self (stop)offStop only the verified managed seat running this command (see Stopping an agent)
--force (stop)offRequest the native hard despawn (graceful:false); it does not bypass authority
--on <instance>class anycast (ps: class scatter)Pin to one manager instance id (multi-manager space); takes the whole id as ps prints it, not a prefix. On a user-auth mesh it also takes a manager's name, its host, or the <host>/<runtime> an unnamed manager shows as (its runtime alone when its presence names no host), when only one of your live managers matches it; otherwise it refuses as TARGET_AMBIGUOUS or TARGET_NOT_FOUND, lists your live managers and gives the command again on each. On a static or open mesh a manager's presence does not carry its instance id, so a name is refused as USAGE_INVALID and the Next asks for the instance id. An empty value (--on "", an unset shell variable) is refused, never treated as absent. A roster principal id (local.…) is refused with a message naming the instance id ps prints
--wide (ps)offPrint each seat as a labeled record that adds the operational facts the manager records: the provider the connector reported serving the model, cwd, pid, spawner, lifecycle uid, the owning manager's instance id and host, and for a --resume seat the session it forked (forked from <id>, with the source title and transcript SHA-256 once a Hermes or Jcode seat has recorded its fork; a carried Claude session prints forked from <host>:<id> with its title, SHA-256 and carried <time>, the time its bytes reached the manager). Model and requested variant stay in the identity row rather than printing twice. A fact the manager did not record (for example a runtime with no real process, a Windows seat whose process ConPTY has not created yet, or a connector that reported no provider) prints nothing, never a placeholder
--json / -j (ps)offOne JSON document (see below). Mutually exclusive with --wide and --plain
--plain (ps)offThe same report without color or marks
--slots (ps)offList the durable static slot rows this manager owns instead of live seats, through the slots command. Mutually exclusive with --wide. A row that is not in the live roster still prints, with LIVE false; a retired row never prints
--no-reconnect (attach)offEnd the attach when its session ends, instead of re-establishing it. For scripts that want one run and one exit code

Control credentials

On a controller stack started with --no-manager, ps refuses TARGET_UNAVAILABLE: no manager runs, so there are no managed agents to list. Bare cotal up adds the manager. A manager preset service started at the root answers instead, and status names it on its Manager row. Bare up then refuses LIFECYCLE_BLOCKED, exit 2, naming that service and the cotal svc stop <name> --instance <id> --space <space> --yes that stops it; once it is stopped, bare up starts the stack's own manager. A stack manager whose process died stays registered, so ps waits out the manager's answer deadline first, then refuses the same way and names bare cotal up, which starts it again. While an up is still starting the stack's manager, ps refuses TARGET_UNAVAILABLE saying the manager is starting, and its Next is to run cotal ps again once up reports it started. That up holds the root's maintenance lock; once it has exited, ps gives the refusal above.

On any other static or open space, such as a legacy stack started with --no-manager, a manager registry that lists no live manager makes ps refuse TARGET_UNAVAILABLE, exit 2: no manager is registered, so none was asked. It used to fail OPERATION_FAILED, exit 5. cotal status <agent> and the attach picker read the same list and refuse the same way. The refusal does not show that no manager process runs, because a manager that is still starting or cannot register is not listed. The registry's own answer stays under error.cause. A registry that could not be read is reported as it was.

When no manager runs or is registered, the refusal's Next names the root of this machine's own stack for the space, as bare cotal up in that folder, because bare up in another project acts on that project's stack. When this machine only registered the space, records no stack for it, or reached it through a --server other than the broker its registration records, it cannot say where the stack runs. The Next then says to start the space's manager where its stack runs and names no local command.

A raw --creds file is refused by ps, stop, attach and the other control commands, because that route mints no endpoint-caller triple; the project folder, or --space against the registry entry, is the route that does.

ps shows the instance id --on takes for each manager that answered, with or without agents. One manager is listed under Manager as its instance, and with several each gets a manager <id> header. With no agents its Next is cotal spawn, or cotal setup when no agent application is set up here, as status reports it.

When ps asks every manager, it then asks each manager that answered for the host name in its own status reply. The host shows as · reports host <host> after the id, or as a reported host row under Manager. It is that manager's own claim, and ps does not check it. Two managers on one host report the same name, and --on never takes it on a static or open mesh. These asks share one 2-second limit. A manager that has not answered by then is listed without a host, and the read stays as complete as the census found it. ps does not show a manager's name. On a user-auth mesh with several of your managers live, the host is the one each manager's presence reports.

Seat JSON envelope

The human ps report is presentation text and is not a stable parsing target. Scripts use --json, one envelope whose result is:

FieldMeaning
kindseats, slots with --slots, or presence when no manager of yours answered on a user-auth mesh
itemsEach seat (or slot) row as its manager returned it, unchanged
managersEach manager instance asked: {instanceId, state}, where state is answered, silent (registered, no answer within the deadline, with the liveness this command established), refused (with its error and code) or contract-mismatch. An answered manager that gave a host name in time also has reportedHost

target is {space, scope: "all-managers"} for the class scatter, {space, scope: "your-managers"} for a user-auth mesh with several of your managers live, {space, scope: "presence"} when none of them answered, and {space, scope: "manager", managerInstanceId} for --on or a user-auth mesh with one. A manager that is not answered makes the read partial (exit 4), with a MANAGER_SILENT, MANAGER_REFUSED or MANAGER_CONTRACT_MISMATCH warning naming it. A silent manager whose liveness is gone is a registration its host never removed. On an auth space its row names cotal endpoint deregister manager <id> --yes, run from the space's own root. On any other space only that manager's own clean stop removes it, so the row names starting it again and stopping it if its host is still available, and says no supported command removes the record if the host is gone. The row stays and the read stays partial.

--slots --wide is refused: --slots lists durable static slot rows, --wide prints live seat facts, and the two answer different questions. Across a multi-manager scatter, --slots prints each manager's rows under its own instance header, the same way the plain ps scatter does.

A revision-32 manager also supports a bounded native slots query through endpoint invoke manager slots --on <instance> --args '{"names":["worker"],"includeRetired":true}'. It point-reads up to 100 distinct aliases and can return their retired slot/head and cleanup observations. The default ps --slots query is unchanged and excludes retired rows. Omitted aliases, live-map absence and stop acknowledgements are not retirement proof. Match the returned principal and lifecycle UID before interpreting any terminal.

Seat observations

These are operator clients over the running manager's control plane. The default row includes the connector, model pin, optional requested variant, and runtime as operational descriptors for the managed row. They do not make a shared display name a unique protocol identity; use --json when unambiguous owner+actor attribution is required. An omitted variant means no override was requested; Cotal does not invent an effective provider default it cannot observe. ps also prints two state facts per managed agent, because they answer different questions: the process fact from the manager's own runtime handle (running with its uptime, or exited with how long it ran), and the mesh fact from the roster (idle / working / waiting / mesh offline, or not in roster when the seat has no presence row at all: a seat that has not joined yet, or one that never did). If the bounded launch wait elapsed without a join, ps prints join degraded (readiness deadline elapsed) and the manager's launch diagnosis. JSON carries joinHealth: { state: "degraded", reason: <diagnosis> }, separate from the unchanged process status. An observed join matching this seat's principal and lifecycle UID clears the diagnostic. The manager does not stop a seat because the wait elapsed. When the seat's connector relays a harness-reported condition, the mesh fact carries its code and how long it has held, so a seat whose turn died on a provider rate limit reads waiting (rate_limit for 40m) rather than a bare waiting, and --json carries the whole condition object. When the connector reports the seat's last work event (presence activeAt), the mesh fact ends with its age, such as · active 3s ago, and --json carries activeAt. A seat whose turn stopped advancing keeps heartbeating, so its presence row stays fresh and this age is what shows the stall. A seat can be running and mesh offline at once: the process is alive and its presence has lapsed. That row says how long, as in mesh offline for <age> with an age such as 3.5h, counted from the seat's last presence heartbeat, which --json carries as offlineSince (epoch ms). The age is read only from the seat's own presence record, matched on its principal and lifecycle uid, so a same-named peer or an older lifecycle never dates it. The manager log names each managed seat that is offline on the mesh while its slot is held (seat offline on the mesh: <name> - last heartbeat <time>; process <state>), including one its watch first sees offline after a reconnect, and each one that comes back (seat back on the mesh: <name>), so a watchdog that only checks process liveness has a line to act on. The manager does not reap or re-key such a seat. The mesh fact is only a verdict while the manager's own presence watch is fresh: when that watch has been silent past the liveness window, or has not replayed the bucket yet, every row prints mesh unknown with the reason instead (--json carries it as meshView: stale | unpopulated), because offline and not in roster would then describe the manager's watch rather than the seat. The manager rebinds a watch that goes silent under a live connection on its own, so mesh unknown normally clears within a liveness window. On a user-auth mesh ps also renders each managed agent's last credential-refresh outcome, fail-closed.

Mode split (chosen up front, never try-scatter-then-degrade):

Manager routing

  • Static / open mesh. Bare ps is a class scatter: it freezes the live manager class from the records registry, merges every registered instance's agents grouped and attributed per instance, and a non-answering instance is shown as registered, no answer within the deadline (never silently omitted). A refused list or a missing answer makes the census incomplete: rows from other instances remain visible, but ps prints an incomplete-census warning on stderr and exits 4 (partial), including with --json. Those rows are not a complete seat count. A contract mismatch prints one plain comparison of the requested and served input/output digest pairs and advises aligning manager versions. The no-answer label means only that the instance is registered and did not answer. It does not say the host is down, because a dead host never deregisters itself and a live one can be slow; if it is gone, deregister it. --on <instance> pins the read to one exact instance id instead. A wrong pin fails loud rather than falling through. On a static or open mesh a well-formed id that the registry lists for no manager is refused TARGET_NOT_FOUND at once, naming the managers that run, as spawn --on does; a registered one that does not answer is reported as manager instance <id> did not answer (nothing else is asked). A credential without that instance's rail is reported as refused by the broker, not as an unresponsive manager. A manager that answers with a refusal is shown with its own cause; "no manager reachable" is said only when nothing answered at all. If the scatter's own registry read fails (the freeze or the reconcile), ps says the manager registry could not be read rather than pronouncing on the managers, which may all be up. When nothing answered and this root's stack manager process still runs, as a hung one does, ps refuses TARGET_UNAVAILABLE naming its pid, and its Next ends that process so bare cotal up starts the manager again.

Versioned endpoint rails

The verdict is scoped to the endpoint rail the request rode. An issued caller rides the versioned ep.v1 rail, a separate subject space from the legacy ep rail, and an endpoint serves both (SPEC 13.15). A manager older than the versioned rail serves ep alone, so it can be running, registered and answering while an issued caller's request reaches nobody. Silence on ep.v1 is reported as no manager answered on the <rail> rail with ep.v1 as the rail, and names both causes it is consistent with: no manager running, or one older than the rail. The CLI cannot tell them apart, because the service registry records no package version, so check whether a manager is running and, if it is, its version. The same scoping applies to cotal run's hosted verbs, which drop the --local suggestion there, since --local drives the run from the calling process and names the caller as its answerer. A hosted verb that no manager answered refuses TARGET_UNAVAILABLE, exit 2, leading with the manager registered for this space did not answer, and its Next is cotal status, which names a stack manager that runs but does not answer, with its pid.

Seat locality

stop and attach route by seat locality. A seat can only be stopped or attached by the manager actually running it, and the class queue does not know which one that is. So on a static/open mesh both verbs first ask every registered instance which one hosts the named seat, then address that instance directly. This happens by default; you do not need --on. When every instance answers and none hosts the name, stop, attach and input refuse TARGET_NOT_FOUND (exit 2) before anything is sent, as they do with --on, naming the managers that answered, with cotal ps as Next. When an instance did not answer for itself, the seat may run there, so they refuse TARGET_UNAVAILABLE (exit 2) and name that instance. When more than one instance runs the name, they refuse TARGET_AMBIGUOUS (exit 2) and name each of them. --json carries each refusal. On a terminal the last two ask instead, listing only the managers that hold the name or did not answer, and the refusal's Next gives the command again with --on for each.

--on <instance> remains the override, for when you already know where the seat lives or the lookup itself is degraded. On a static or open mesh, stop --on, attach --on and input --on with an id the registry lists for no manager are refused TARGET_NOT_FOUND at once, naming the managers that run, as spawn --on is. On a user-auth mesh, the exchange selects one authorized manager for a short-lived manager-caller view. --on requests a specific instance. Without it, a command run from a managed seat's shell selects the manager that launched the seat (COTAL_MANAGER_INSTANCE), and any other caller's selection must be unique. Discovery and the command use that instance route. The caller gains no registry read or scatter permission. An absent, ambiguous or unauthorized selection refuses before sending the command. With several of your managers live, the refusal lists each one on its own row with its name, instance id, host, activity and the time its process started. A manager started without a name is listed as <host>/<runtime>. A manager started by a cotal before 0.100.0 puts no host in its presence, so its host reads not reported (started by an older cotal) and it is listed by its runtime, such as tmux. Restarting it with the current cotal shows its host. Its Next gives your command again with --on for each, using the name, <host>/<runtime> or runtime when that picks the manager out, and the instance id otherwise. The code stays PERMISSION_DENIED and the exit 2. who --all lists the same managers at any time. By default, when every manager you registered has exited, the refusal says to start one with manager start; the command is not sent to the local manager.

ps and status <agent> do not refuse: they read each of your live managers, as the user-auth mode below describes. stop, attach and input with an agent name read them the same way and run on the manager that holds the name, without asking. A name held on several of them, or found on none that answered, is settled as on a static mesh above, among those managers only, and a manager whose read fails counts as one that did not answer. A name every manager answered without is refused TARGET_NOT_FOUND, naming the managers searched. On a terminal, spawn -d, models and persona ask instead of refusing. Every manager the exchange counts is one of yours in the space the command resolved, from the folder or --space, so the question lists those managers with the most recently started first, and that one is the default. When presence cannot be read, the managers are listed by instance id under a names unavailable: line, as for a bare spawn. The command then runs on the manager you pick, as the Next would run it. The question and a Manager chosen receipt, with the manager, its instance id and the --on that skips the question, go to stderr. Cancelling the question does nothing and exits 0. Without a terminal on stdin and stdout, with --no-input, with --json, which implies --no-input, or with COTAL_NO_PROMPT=1, the command refuses as above. A --json caller passes --on, and the result's target.managerInstanceId names the manager that answered. A bare spawn asks where the agent runs before any of this, and a managed launch on a manager that is not known to run on this host then asks for its folder (see spawn).

A trusted hosted composition may opt into an interactive local primary. Under that policy, unpinned human control selects the genuine local Manager up front, even when your own remote Manager is live; an unavailable local Manager refuses without remote substitution. This affects all interactive Manager verbs, not only ps. Select your remote Manager explicitly for its spawn, run or terminal operations. Explicit pins and managed-seat launching bindings keep their rules. There is no user flag for the policy, and stock up and recovery defaults do not enable it.

A connection that the broker refuses, or that does not open within 5 seconds, sends nothing. A manager command then connects again, as do send and the other commands that open a one-shot connection, up to 4 attempts 1 second apart. A view the auth service refused for a moment, or an auth service that answered late, then just works. When every attempt fails the command says nothing was sent and is refused, exit 2: PERMISSION_DENIED when the broker refused each attempt, TARGET_UNAVAILABLE when none opened in time. The broker's own words stay in the native detail. On a user-auth mesh the space's auth service logs why it refused.

A seat is reported as not found only when every reachable instance answered for itself. An instance that stayed silent past the deadline, or that refused the read rather than answering, said nothing about which seats it hosts, so the seat may be running on it. That case reports that the location could not be established, names the instances that did not answer, and states outright that it is not a report that the seat is gone. Read it as unknown and retry with --on <instance>; a retry loop that treats it as "already gone" stops looking for a seat that is still running. A single manager cannot tell "hosted elsewhere" from "does not exist": it answers not-found for both, which is why the search asks all of them and why an incomplete search concludes nothing.

  • User-auth mesh. With one of your managers live, cotal ps reports what that manager knows about your agents (an instance-addressed read against its in-memory roster, owner-filtered). With several live, ps asks nothing: it reads each manager the exchange counts live through its own one-instance view, one after another, and lists each under its own manager <id> header, as the class scatter does. A manager that does not answer is listed as silent and makes the read partial (exit 4), and the Next names each one by instance id: a manager that ended without a clean stop stays registered under its id. Managers of other owners are not asked, so completeness across the space is not claimed. status <agent> and who read your managers the same way. When none of your managers answers, or none is live, ps lists your agents as presence shows them, read-only, with their state, application and host, and exits 0. It says that no manager answered, and its Next starts one: bare cotal up on the root of the space's own stack, as for a static stack above, and cotal manager start anywhere else. --on and --slots keep refusing TARGET_UNAVAILABLE, exit 2, when their manager does not answer. Your ledger row needs the admin scope to reach ps at all; spawn alone is refused by the broker (the ep tier boundary). A broker-refused read keeps that refusal and adds the documented scope requirement and cotal auth grants remedy. The broker does not identify whether the missing permission is for the route, command or target; the CLI does not substitute another caller or retry on a different route.

Terminal attachment

attach streams and drives an agent's terminal on the pty runtime; detach with the escape key (Ctrl-] by default; see the detach-key preference). The key is recognised as the legacy control byte and as the kitty keyboard protocol and xterm modifyOtherKeys encodings of the same press, so a terminal with either protocol enabled detaches too. It does so over a one-use, holder-bound mesh session (SPEC §13.6): the manager replies with a signed session grant (never a 127.0.0.1 URL), the CLI redeems it once over the broker, and the browser console (cotal console) drives the same session. stop and attach need a running manager to talk to. On a static mesh they are cross-agent admin operations. On a user-auth mesh, your own agents (any agent under your owner) need only the spawn scope; another owner's agent needs admin on your ledger row (identity & auth). Launch detached agents with spawn --detach.

pty is the only runtime that streams a terminal. A seat started by the tmux, cmux, orca or herdr runtime runs in a surface the manager does not own, so there is no stream to give and attach refuses once with UNSUPPORTED, exit 2, carrying that runtime's own guidance for watching the agent where it runs, and a Next that names cotal send and cotal watch. The reconnect loop below does not retry that refusal, because asking again cannot change it.

Attachment reconnection

attach reconnects when the link dies. A session lives on a network link, and a laptop that sleeps, a VPN that drops or a wifi handover kills it. When that happens attach prints [cotal: connection lost, reconnecting] on stderr and starts asking the manager for a new session: a fresh grant, a fresh per-session credential, a fresh connection, so every attempt re-runs the same authorization the first attach did. On success it prints [cotal: reconnected], the manager repaints the seat's current screen the way it does for any attach, and you carry on in the same terminal. Retries wait 1s, 2s, 5s, 10s, then 30s, for as long as the seat exists. The detach key is read the whole time the loop runs, the waits and the attempts alike, so a reconnect never traps you: press it while a session is being established and the attach ends there, and a session that lands behind the press is handed back to the manager rather than left holding a slot. Everything else you type while there is no session is dropped rather than queued, so keystrokes aimed at a terminal that turned out to be frozen, Ctrl-C included, are not delivered to the agent by a reconnect you did not know had happened. That starts before the first session, not at the first reconnect: at a terminal, attach reads and drops what you type while it is still resolving the mesh, so a key struck at a prompt that has not come up yet does not reach the agent when it does. The terminal is in raw mode for the whole reconnect, including when the link died before the first session finished opening, so the detach key works there too instead of echoing as ^].

A pipe carries script input. For example, printf 'ls\n' | cotal attach --name web is buffered until the session opens. Buffering continues across reconnects, so tail -f log | cotal attach --name web does not lose the part of its feed written while the link was down. Only a terminal gets the reader; --no-reconnect keeps the old behaviour on both.

It stops on its own when reconnecting cannot help, and says why: a manager that refuses the attach exits non-zero with the manager's own message, and a reconnect that finds the seat no longer there (despawned, or its agent exited while the link was down) exits cleanly with seat <name> is gone. It stops the same way on a refusal the CLI raises itself, such as a broker that denies the request. A local connect refusal that retrying cannot fix, such as a static-auth mesh whose seed is now missing, also exits non-zero with the refusal's own sentence. A broker that is still unreachable, or a user-auth service that does not answer, keeps the loop trying in silence. A refusal that could still pass, such as a manager at its session ceiling, is relayed in the manager's own words while the loop keeps trying, once per refusal rather than once per attempt. Pressing the detach key ends the view with Detached. A remote terminal-session end, including process exit or manager shutdown, prints Attach session ended. Neither report claims the agent is still running or that its cleanup completed. Use cotal ps to check its current state. --no-reconnect restores the single-session behaviour.

Each reconnect also hands the abandoned session back to the manager, over the first link that can carry the message, so an attach that flaps does not eat the manager's session slots one outage at a time. If that message never gets a link, the attach says so when it ends. The live-session ceiling defaults to 64 concurrent sessions (--max-sessions); the browser console opens one session per pane, so a dashboard over a large mesh should size for agents × panes. Hitting the ceiling refuses before a credential is minted and names --max-sessions.

Session grant redemption

Which mesh attach resolves also decides how it redeems the grant. On a registered open mesh there is no local seed. The CLI connects bare, the same way other control commands already do, and the session rail is the caller rail that a real open-mode connection already reaches. Telling the operator to re-register the root is false: the registered root is already the contract. On a static-auth mesh the grant is still redeemed by minting a short-lived session-scoped credential from the seed at the root the mesh resolved to, never from a .cotal found by walking up from whichever directory you happen to be standing in. The difference is not hypothetical: ~/.cotal exists on every install because the mesh registry lives there, so a command run anywhere under your home directory but outside a project used to mint from your home directory's trust and present it to a broker that trusts a different chain, which surfaced as a bare authorization failure that named nothing. A directory that does hold another chain for the same space is now reported on the way past, and not obeyed:

! this directory resolves to /Users/you, whose .cotal/auth holds a DIFFERENT trust chain for space "team".
  attach used /Users/you/projects/app, the root this mesh resolved to. The other one is not being used, and is worth a look.

When a static-auth mesh holds no seed at the resolved root, attach refuses and names what it resolved, the broker and the root, instead of describing a directory it did not use and instead of taking the open-mode path. An authenticated registry entry with a missing seed is still authenticated. On a USER-AUTH mesh attach reads no seed. It sends your login and the session grant to the auth service, which issues a session-caller bearer only if your owner and actor hold that session. The connection it opens expires with the session grant.

Console authority

Terminal bytes stream over the mesh; the manager's own HTTP/WS face serves the console. That endpoint binds loopback by default, so nothing is exposed by accident; cotal up --host <addr> passes its bind address down, which is what lets you reach the browser console (cotal console) for an agent whose manager runs on another machine. attach does not use that face: it redeems a signed mesh session grant over the broker instead (see above), so it reaches a remote manager regardless of the bind address. A bare cotal supervise and an embedded manager stay machine-local. Set it directly with supervise --console-host <host>.

That address is recorded on the mesh and carried forward, because it is a decision rather than something later commands can work out for themselves (a broker dial address is not a manager bind address). Every later manager launch for the same mesh reuses it, including a same-root cotal up repair and an adopted preserved or restored listener. A manager replacement does not quietly move a reachable attach face back to loopback. Passing --host again overrides it, so you can widen or narrow exposure whenever you like; a mesh that never asked stays loopback-only and records nothing.

Because that face mints terminal read and write authority for every managed agent's browser session, it is credentialed in two tiers. A mesh caller receives a ticket bound to the single agent the manager just authorized, single-use and short-lived, so one authorized attach can never be re-pointed at someone else's agent. The console token is the operator's own, reaches every agent, and is printed only to the manager's output. The roster, the live feed, and the PTY stream all answer 401 without one; the static console shell is served openly, since it describes no agent.

input

cotal input --name <n> --text <text> [--no-enter] [--on <instance>] [--space <s>]
FlagDefaultMeaning
--space <s> / --server <url> / --creds <path>resolved meshWhich manager to reach
--name <n>Managed agent to type into (required)
--text <text>The text to type, taken verbatim (required)
--no-enteroffType the text and stop there, without pressing Enter
--on <instance>class anycastPin to one manager instance id using the same rules as attach

Types one line into a running agent's terminal, as if you had typed it there, and returns. This is the half of attach that a program wants: attach is a live stream that holds a session open and expects a terminal on your side, so a script, a cron job or a web UI cannot use it to send a single line. input is one authorized call.

What it is for is harness commands. A line beginning with / is not chat and not a message: it is something the agent's own harness handles, and the only way in is the keyboard.

cotal input --name reviewer --text "/compact"          # ask the harness to compact its context
cotal input --name reviewer --text "/model opus"       # switch its model
cotal input --name reviewer --text "hold on that PR"   # ordinary typing works too

Input quoting

Quoting. --text takes a value, so a payload starting with / survives as written. A payload starting with a dash needs the = form, because the shell-style --text --foo is ambiguous and is refused rather than guessed:

cotal input --name reviewer --text=--verbose          # dash-leading text: use --text=<value>

Enter is pressed by default, since a command typed but never submitted has not been delivered. --no-enter types the text and leaves it sitting at the prompt, which is how you stage a line and send it later.

At a Claude selection menu, including AskUserQuestion, text input is refused before any bytes are written. Pasting a number or an answer there can otherwise confirm the highlighted option instead of the requested answer. Use cotal attach --name <n> and the menu's arrow keys and Enter. This refusal also applies to --no-enter.

Nothing comes back but a Terminal input written receipt. It names the agent, space and manager, the bytes the manager reported writing (counting the trailing carriage return) and whether Enter was requested. It confirms the write to the terminal only. Whatever the agent does next shows up where its output already goes: the mesh, its transcript, or an attach.

A write the manager cannot confirm, such as a terminal write that failed partway, exits 3 with Outcome unconfirmed and is not typed again. Part of the text may already be at the prompt, so check the seat before you send it again.

With no manager running, as after cotal service stop stack-manager, input refuses TARGET_UNAVAILABLE (exit 2) before it sends anything and names bare cotal up, as ps does. stop and attach find their seat the same way and refuse the same. A manager that stays registered but does not answer, such as one that crashed, leaves input and stop waiting out its answer deadline. They then refuse TARGET_UNAVAILABLE too, since nothing was sent, with cotal status as the next step.

Input authority

This one is operator-only, and more narrowly than stop or attach. Those two are granted to anything holding spawn, so an agent can stop and attach to seats under its own owner. input is not: it is granted only to operator credentials, which on a user-auth mesh means your ledger row needs the admin scope, the same scope ps already needs there. The reason is that a write into a terminal is control of whatever is running in it, and on a user-auth mesh the own-owner rule covers every seat under you, not only the ones you launched: a spawn-scoped agent could otherwise type into a sibling it never started. Seat locality is still resolved for you.

Only the pty runtime can be typed into. The external terminal runtimes (tmux, cmux, orca, herdr) attach to a process they do not own, so they have no input stream for it and the command refuses by name rather than dropping the keystroke.

Terminal input receipts

input <agent> <text> writes through the selected manager's native input command. Use --text when the value begins with a dash; do not combine duplicate positional and flag sources. --no-enter omits the trailing Enter request. JSON and plain receipts identify the answering manager and its reported byte count without repeating the input body. An acknowledgment is not proof of application consumption or task completion. Unknown results are not retried. Managed-launch failures retain native request and accepted-goal identifiers when a reply supplied them, including when following that accepted goal failed. No identifier is invented for a silent submission, and a missing effect outcome is not a proved pre-execution refusal.

Goal observation keeps the caller's authorized route. A class caller reads its original goal on that class route; an explicit --on or credential-bound instance stays pinned. Learning the accepting instance ID does not confer an instance route or cause another launch. The native follower verifies the returned fact against the original caller and goal. A launch the manager refuses before its first effect, such as a missing persona file or connector, carries not-executed. The CLI reports it refused with exit 2 and prints the manager's reason. A missing persona names the manager, its host and the file it looked for, which is created on that host. A missing connector is DEPENDENCY_UNAVAILABLE, as it is for a spawn in this terminal, and the refusal names the manager's instance and the host its status reports. An unpinned launch that no manager received is refused with TARGET_UNAVAILABLE and exit 2. With no manager running, the CLI says so before it sends and names bare cotal up. When registered managers do not answer the launch describe, the launch was never sent and is refused the same way. The reason follows ps when this folder records its manager stopped, starting or running but silent. When every live manager reports classSpawn: false, the refusal is DEPENDENCY_UNAVAILABLE: it names each manager and its host, says none has an agent application installed, and names the cotal ext add that adds one. A registered manager whose rail the broker reports empty is skipped without waiting and named in a MANAGER_SILENT warning; the reason comes from the managers that answered. Both refusals say to add the application where the manager runs, not on your machine. A manager reads its applications when it starts, so restart it after adding one. A failure after the manager reserved the name or wrote a launch file keeps its native outcome, and the CLI never reclassifies it from its message.

Stopping an agent

stop <agent> requests graceful native despawn. --force requests the existing graceful:false contract; it does not grant authority or bypass incarnation checks. stop --self uses only a verified managed seat caller and its attributed manager. It accepts no other target or credential. Missing, mismatched and unsupported user-auth seat context refuses without switching to the operator. JSON and plain modes show the acknowledged native result, not a separate measurement of process exit. Stop and input do not seed connector installations.

Failed name resolution refuses before a stop, terminal input or first attach is submitted. The receipt retains the inspect error and responding manager. It records local pre-submission proof without inventing a native effect outcome.

When the manager refuses a stop, terminal input or first attach before anything ran, for example because the agent is not running or the caller may not control it, its reply carries not-executed. The CLI reports refused with exit 2, names the agent and prints the manager's reason on one line. A failure after an effect may have started, such as a terminal write the runtime rejected, stays unknown with exit 3, still shows the manager's reason, and is not retried.

Managed restart policy

Managed imperative spawn accepts --restarts <n> --restart-window <ms> as a pair, using the existing native supervise object. Both values must be positive safe integers. Direct foreground launch and the legacy manifest form reject these flags instead of ignoring them.

On this page