Write your first extension
Docs/Extend/Write your first extension

Write your first extension

Edit on GitHub
docs-site/extend/first-extension.md

This 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 workspace

2. 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 a read grant, count_files is disabled (never offered to the model) and greet keeps working.
  • No fs, no child_process. Files, processes and the network come through ctx: 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 --json emits it as data.

3. Test it

orrery ext test ./hello

This 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 hello

Then 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

CallWhat 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 · .markdownDescribe 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.signalAn 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 the orrery-guest crate.
  • Any language with WebAssembly: runtime = "wasm" with wit-bindgen and the published .wit.
  • Any program: runtime = "process" and a [process] table naming the command.
  • Rust, as a process: runtime = "process" with the orrery-ext-sdk crate and fn 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), and orrery_ext_sdk::config() / config_get(key) for the configuration.

The worked examples, one per runtime, are in harness/extensions/examples.

Next