Input, timers and questions
Edit on GitHubAn extension can drive a run as a client does: send it input, keep it going with a driver, wake it with timers, and watch what it does. It can also ask the person a question, tell them something, and show a status. None of it draws its own screen: the host and each client draw these, so they look the same everywhere.
Send input
// TypeScript
await ctx.run.send("Check the build again.", { target: "queue", shown: { lead: "Re-checking the build" } });
const status = await ctx.run.status(); // { state: "running", pending: 1 }// Rust, inside a process extension: the same call on the host handle
use orrery_ext_api::facades::RunFacade;
let host = orrery_ext_sdk::host().expect("served by orrery");
let status = host.run_status().await?;targetis how the input joins the run, as a client sends it:steer,queueorinject.toismain(the default) or an agent the extension spawned. Sending to main needs therun.sendaspect, shown at install.shownsays how the thread draws the input, and it is never drawn as the person's:"message"(the default): drawn as the extension's, named after it.{ lead: "…" }: a one-line lead in its place, as a turn nobody typed."hidden": not drawn; still in the session log and the audit.
- The reply says whether it started the run, was delivered, was queued, or was rejected.
- The session log keeps it as an
ext.sendentry with itsshown, so a reloaded session draws it as it was drawn live. A run it started stores the message withby: ext:<id>(Who said it). - Input counts against the run's budget.
limits.extensionRuns(3 by default) caps how many runs an extension starts with no person's input in between.
Drive the run
A driver answers what comes next after each completed turn. Name it in config so the host asks it:
driver = "goal" # the driver an extension registered; a name nothing registered fails at startdriver: {
name: "goal",
afterTask({ last, tasks }, host) {
if (tasks >= 5 || /done/i.test(last)) return null; // nothing: the run ends
return { text: "Keep going on the next item." };
},
},- An answer that is nothing ends the run as it would have. A driver that does not answer in time is also nothing: the log and a notice say why.
- Nothing returns "continue". A run stays alive by input the extension sends, or by a hold (below).
Timers
// TypeScript
const id = await ctx.timer.set(60_000, "poll-ci", { hold: true });
await ctx.timer.cancel(id);
export default defineExtension({
onTimer(tag, host) {
if (tag === "poll-ci") return host.run.send("CI poll due.", { target: "queue" });
},
});// Rust: the host calls the handler for every timer this extension set
orrery_ext_sdk::on_timer(handler); // handler gets the tag
let id = host.timer(TimerSetParams { /* after_ms, tag, hold */ }).await?;- The host holds the timer, so a parked extension costs nothing while it waits. A timer that fires starts the extension if it is parked.
hold: truekeeps the current run alive until the timer fires or is cancelled, for at mostlimits.holdMs(10 minutes by default).
Watch the run
An extension can subscribe to the run's bus. Declare the kinds it wants, so the host starts it when a session opens instead of on first use:
export default defineExtension({
subscribes: ["session", "turn", "tool", "context"],
onEvent(received, host) {
if (received.t === "event" && received.event.kind === "turn") { /* … */ }
},
});In Rust, declare the kinds at load with ctx.subscribes(&[EventKind::Turn, EventKind::Tool]) and read them with ctx.events().subscribe(&kinds); a process extension is then started when a session opens, as a TypeScript one is.
The kinds are run, agent, message, wait, lock, state, session, turn, tool, context. A watcher observes and lags: publishing never waits for it, and it cannot change anything. A lagged event says how many it missed. A body is read on demand with session.read, not carried on the bus.
Ask the person
const answer = await ctx.person.ask("Which branch?", ["main", "dev"]); // one choice
const ok = await ctx.person.ask("Push?", { kind: "confirm", detail: "to origin" });
const note = await ctx.person.ask("Why?", { kind: "text", multiline: true });| Kind | Asks | Answer |
|---|---|---|
| (none) | One choice from a list, or free text. As before. | A string. |
choose { choices, many } | One choice, or several with many: true. | A string, or JSON for many. |
confirm { detail } | Yes or no. | true or false. |
text { placeholder, multiline } | A line, or several. | A string. |
form { fields } | Several fields at once, drawn as a form. | JSON. |
A run that cannot ask (orrery run -p, with no client to answer) refuses, and the refusal says why. In Rust, host.ask(question, AskKind::Choose { choices, many }) takes the same kinds (orrery_ext_api::wire::AskKind).
Tell the person
await ctx.ui.notify("Build finished.", "info"); // "info", "warn" or "error"
await ctx.ui.status("ci", "CI: 3 of 5 green"); // one segment of the status line
await ctx.ui.status("ci", null); // clear itnotifyis the host's own toast, labelled with the extension. With no screen (orrery run), it goes to stderr as<id>: <text>(warning:orerror:before the text by level) and, with--json, as the eventCUSTOM orrery.notice {ext, level, text}.statussets one segment of the status line, labelled with the extension. Only the latest text shows.
To show something larger, place a surface in a band row, a pane beside the thread, or the footer. The same id replaces it in place; hide takes it away:
await ctx.ui.show({ id: "ci", placement: "band", title: "CI", surface: ctx.ui.text("3 of 5 green") });
await ctx.ui.hide({ id: "ci" });The agent has the same two calls as the core tools ui.show and ui.hide, and the person places with config or /ui. The placements, the fallback a client uses when it cannot draw a surface, and the surface kinds (charts, rasters, images) are in Show things: placements.
Limits
- An extension never draws. The client in use draws what it sends, with that client's own elements. There is one client per screen; an extension is never a second one.
- A segment outlives its writer. It stays until the extension clears it, also after the extension is parked or has crashed, when nothing updates it any more. Clear it before you stop.
- A placed surface is session content, not a notice. It stays until its owner replaces or hides it, or the extension is unloaded. A placement that a
ui(<placement>)rule denies is refused, naming the rule. Anaskrule is refused too: a placement cannot wait for an answer, so useallowordeny. - An idle extension is parked. After 10 minutes with no call in flight, no open stream and no event subscription or watch, its process is stopped, and the next call starts it again. An extension that keeps a segment current should subscribe to the events it reflects, or set a timer: a timer wakes a parked extension.
Keys
An extension may bind a key to one of its commands. Declare it in the manifest:
[[provides.keys]]
key = "ctrl+g"
command = "git status"A key runs a command, and a command is a tool call. [keys] in config.toml overrides a declared key. Esc, Ctrl+C, Ctrl+D, Enter and Tab belong to the host: a manifest that binds one is refused at install, naming the key.