Write your first extension
Edit on GitHubThis page builds a small Node extension with one tool, tests it, and installs it. You need orrery and Node.js.
1. Declare it
Make a folder and write its manifest, hello/orrery.toml. The manifest is the contract: nothing outside it is available at runtime.
mkdir hello[extension]
api = "orrery-ext/1"
name = "hello"
version = "0.1.0"
runtime = "node"
[provides]
tools = ["greet", "count_files"] # → hello.greet, hello.count_files
[requires]
read = ["$WORKSPACE/**"] # count_files lists the workspace2. Implement it
hello/index.mjs:
import { defineExtension } from "@getorrery/ext";
export default defineExtension({
tools: {
greet: {
description: "Say hello to someone.",
input: {
type: "object",
properties: { name: { type: "string", description: "Who to greet." } },
required: ["name"],
},
async run({ name }, ctx) {
return ctx.ui.text(`hello, ${name}`);
},
},
count_files: {
description: "How many files are under a directory.",
input: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
},
requires: ["read"],
async run({ path }, ctx) {
const listing = await ctx.fs.list(path, { recursive: true, limit: 1000 });
const files = listing.entries.filter((e) => !e.is_dir);
return ctx.ui.table({ columns: ["what", "count"], rows: [["files", files.length]] });
},
},
},
});You do not install @getorrery/ext yourself: the SDK travels inside the orrery binary, and the host puts it in node_modules/@getorrery/ext beside your extension before starting it.
Three things to notice:
requires: ["read"]is per tool. Without areadgrant,count_filesis disabled (never offered to the model) andgreetkeeps working.- No
fs, nochild_process. Files, processes and the network come throughctx:ctx.fs,ctx.proc,ctx.net,ctx.creds. Each call is checked by the policy engine, budgeted, cancellable and audited. ctx.ui.table(...)describes a table; it does not draw one. Every client draws it its own way, and--jsonemits it as data.
3. Test it
orrery ext test ./helloThis runs the extension against the mock broker that ships in the binary: the same ledger and the same denials as a real session, with no model and no network.
4. Install it
For development, link the folder so your edits apply without reinstalling:
orrery install ./hello --link
orrery ext show helloThen ask for it in a session:
orrery run -p "Use hello.greet to greet Ada"A linked install is always unpinned and marked Development in the ledger.
What ctx offers
| Call | What it does |
|---|---|
ctx.fs.list(path, { recursive, limit }) | What is under a folder. A path this call may not read is not named. |
ctx.fs.read(path, { limit, offset }) | At most limit bytes. |
ctx.fs.write(path, text, { atomic }) | All or nothing by default; reverted if the call is cancelled. |
ctx.proc.run(program, args, { cwd, timeoutMs }) | Run a program, contained and time-limited. |
ctx.net.fetch(url, { method, headers, body }) | One HTTP request, policy-checked. |
ctx.creds.get(name) | A named credential. |
ctx.ui.table · .text · .markdown | Describe a surface. There is no .chart, .raster or .image: write those as objects (surface kinds). |
ctx.config · ctx.config.get(key) | The extension's [extensions.<id>] table, live; get reads one key. |
ctx.agents.spawn({ task, name, tools, model, maxTurns, attached, … }) | Start an agent under the calling one; answers its id. |
ctx.agents.join(id) | Wait for one of its agents to end; answers its report. |
ctx.agents.children() · .inspect(id) · .message(id, text) · .stop(id) | The agents it spawned, and only those. |
ctx.person.ask(question, choices?) | Ask the person; answers their answer. Rejects in a non-interactive run (orrery run -p). |
ctx.person.ask(question, { kind: "confirm", detail }) | Ask for a kind of answer: choose { choices, many }, confirm, text { placeholder, multiline }, form { fields }. |
ctx.model.complete({ prompt, system, model, maxOutputTokens }, { onEvent }) | One completion through the host, no tools: the caller's model unless model names "session", { config: key }, { role } or { named }. Answers { text, model, usage, … }; onEvent streams it. ctx.model.list() lists what it may name. |
ctx.session.info() · .read({ limit }) · .append(text) | What session this is; its messages since the last compaction; a message the model sees from the next pass. |
ctx.run.send(text, { target }) · ctx.run.status() | Send input to the run (steer, queue, inject), shown as the extension's; the run's state. |
ctx.timer.set(ms, tag) · .cancel(id) | A timer the host holds; onTimer(tag, host) in the definition runs when it fires. |
ctx.tools.hide(globs) · .show(globs) | Hide tools from this session, or lift its own hides. |
ctx.ui.notify(text, level) · ctx.ui.status(key, text) | The host's own toast, and one segment of its status line, labelled with the extension. |
ctx.ui.show({ id, placement, surface, fallback?, title? }) · ctx.ui.hide({ id }) | Place a surface in the thread's band, a pane or the footer, or take it away (Show things: placements). |
ctx.signal | An AbortSignal that fires when the call is cancelled. |
A denied call throws BrokerDenied. Catch it if the tool can do something better; otherwise let it through and the client shows a denied outcome.
A tool may also carry a command a person types: tools: { toggle: { command: "theme toggle", run } }.
Hooks
An extension can also answer the host at points of the loop, as fields of the definition. A tool.before gate that refuses a force-push:
export default defineExtension({
interceptors: {
"tool.before": {
name: "no-force-push",
mode: "gate", // allow or deny; "rewrite" may change the input
tools: ["builtin.bash"], // the host asks only for these calls
run: ({ input }) => (/push .*--force/.test(input.command) ? { deny: "push a new branch instead" } : null),
},
},
contextSections: [{ name: "recap", fromState: "recap" }], // a state key shown each pass, no call
async onTimer(tag, host) {
await host.run.send(`timer ${tag} fired`, { target: "queue" });
},
});Seeing or changing calls needs [requires] intercept = ["tool.before:builtin.bash"]. The other hooks — driver, contextBuilder, memory, compactor, toolSelector, mcpConnector, subscribes with onEvent — follow the same shape; the SDK's README lists what each is given and answers. The worked example is harness/extensions/examples/node-every-call.
Other languages
Only the runtime line and the binding change:
- Rust, sandboxed:
runtime = "wasm"with theorrery-guestcrate. - Any language with WebAssembly:
runtime = "wasm"withwit-bindgenand the published.wit. - Any program:
runtime = "process"and a[process]table naming the command. - Rust, as a process:
runtime = "process"with theorrery-ext-sdkcrate andfn main() { orrery_ext_sdk::serve(MyExtension::default()) }. The same calls under Rust names:ctx.agents_handle()for spawn, join and the rest,ctx.ask_person(question, choices), andorrery_ext_sdk::config()/config_get(key)for the configuration.
The worked examples, one per runtime, are in harness/extensions/examples.