The platform is Bun/TypeScript. Your agent's tools don't have to be — they run as subprocesses or MCP servers, so any language works. What makes a tool usable is a grant in the agent's settings file.
A CLI tool, in any language
Put the executable in the workspace and grant exactly it. From the notes-agent example:
"allow": ["Bash(python3 tools/stats.py:*)"]
That grants one script. Not python3, not Bash — the pattern names the command line the agent
may run, and :* allows arguments after it.
The script itself is unremarkable:
#!/usr/bin/env python3
"""Counts notes by status and type."""
import collections, pathlib, re, sys
issues = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "okf/issues")
...
Run it yourself to see what the agent sees:
$ python3 examples/notes-agent/tools/stats.py examples/notes-agent/okf/issues
1 note(s) in examples/notes-agent/okf/issues/
by status: open=1
by type: note=1
Then ask the agent to run the stats tool, and it invokes the same thing as a subprocess.
Grant patterns worth knowing
| Pattern | Allows |
|---|---|
Bash(python3 tools/stats.py:*) |
that script, any arguments |
Bash(mycli find:*) |
one subcommand of a company CLI |
Bash(mycli *) |
every subcommand — much wider; be deliberate |
Edit(okf/issues/**) |
writes under one directory |
Read |
reads anywhere the process can reach |
Narrow patterns are the whole point. Bash(mycli *) and Bash(rm:*) differ by a filename, and
only one of them is a mistake you'll notice later.
Runtimes: how a Python tool works from a Bun platform
The executor spawns claude with this workspace's environment — the platform process's own,
with whatever the manifest's env: block declares layered over it. Two variables are the
wiring's rather than the bag's: CLAUDE_CONFIG_DIR, set when the platform owns a Claude config
dir for this workspace, and ANTHROPIC_API_KEY, which reaches the child when it does. They are
one decision, made once for both executor kinds — see
permissions for why the dir
moves at all, and environment variables for what the key does.
The resolved environment is a value, never an assignment to process.env — so two workspaces
booted in one process get two bags, and neither child sees the other's credentials. Within one
workspace, the child still inherits the whole bag.
That means the child inherits your PATH — unless the workspace's own provider sets one — and a
runtime is available to the agent if it's on the PATH of the process you started. There's no
manifest field for a workspace runtime and no venv activation step inside OpenStation. To give
the agent a project venv:
source ~/openstation/notes/.venv/bin/activate
bun packages/cli/src/index.ts notes dev
Or set it explicitly in the unit file / container environment that starts serve. Whatever
python3 resolves to for the platform process is what the agent gets.
Skills: teaching a procedure once
A skill is a folder under .claude/skills/<name>/ containing SKILL.md:
---
name: note
description: Use when filing or updating a workspace note under okf/issues/ — defines the required frontmatter and structure.
---
# Filing a note
One note per subject, at `okf/issues/<kebab-case-slug>.md`. Frontmatter is required:
...
Claude discovers skills itself. OpenStation never reads them, never registers them, and there's
no manifest entry — dropping the folder in is the whole installation. Grant Skill in the
settings file and the agent can use them.
Skills are where format and procedure belong, so the charter can stay about judgment. Be literal
about paths in a skill: an early version of the notes skill said "keep index.md current" and
the agent created okf/issues/index.md.
Offering buttons: send-buttons
An agent can offer the user a set of options, the same way it sends files — a fenced block in
its reply, one id|label per line:
Here are the proposed matches for Ada — 3 photos:
```send-buttons
ob:approve-ada-7f3a91bc2d10|✅ Yes, that's Ada
ob:reject-ada-7f3a91bc2d10|❌ Not Ada
```
The block never reaches the user. On a channel with buttons (Telegram) the options render as an inline keyboard; everywhere else they degrade to text — label and id both, because the id is what has to be sent to choose:
- ✅ Yes, that's Ada → reply "ob:approve-ada-7f3a91bc2d10"
A press comes back as an ordinary message whose text is the id. There is no callback to handle and no second surface to write: it arrives in the same conversation, resuming the same session, exactly as if the user had typed it. That is also why a button-less channel loses nothing.
Choose ids the agent can act on. The id is the whole payload, so it has to carry everything
needed to honour the press — and it has to keep meaning the same thing later, since a stale
button is still pressable. Prefer a key over a row id: openbook's ob:<verdict>-<person>-<photo hash> survives a rescan, where a match id would be reassigned and the button would silently
retarget a different match.
Give ids a shared prefix and a press costs nothing. A
bridge.inbound
stage declaring prefix: ["ob:"] answers the press deterministically, instead of spending a
Claude turn on it.
What is refused, and why
Refusals are logged (bridge: refused N button(s): <id> (<reason>)) and the rest of the reply
is still delivered.
| Rule | Reason | Why |
|---|---|---|
An id starting with approve: or deny: |
reserved |
Those are the approval gate's own decisions, and a decision arrives as message text. An agent that could author one could approve its own held turn |
| An id over 62 bytes | id_too_long |
Telegram's callback_data limit is 64 and the namespace takes 2. Over it, the press is acked and silently dropped — a button that visibly does nothing |
| More than 10 options in one reply | over_cap |
Telegram lays them out as one keyboard row; a model asked to review 30 photos will otherwise author 60 buttons |
| An empty id | malformed |
Nothing to send back |
Two things are fixed up rather than refused: a label over 64 characters is truncated, and newlines in a label collapse to spaces — the option still works, where a dropped button would not. A repeated id keeps its first label. Both caps are the platform's, not manifest fields.
A line with no | uses the id as its own label, and a reply that was only the block gets a
default line so nothing delivers blank. send-buttons and send-files compose: a reply may
carry both.
Tell the agent about the block in its charter or a skill; nothing prompts it to use one on its own.
MCP servers
MCP servers are declared the Claude-native way — a .mcp.json in the workspace, which Claude
Code reads on its own — and then granted in the settings file:
"allow": ["mcp__crm__*"]
The grant pattern is mcp__<server>__* for a whole server, or mcp__<server>__<tool> for one
tool.
There is no openstation.yaml field for MCP servers. OpenStation has an internal tool-surface
abstraction (McpToolSurface, CliToolSurface) that can inject server configs and grant
patterns programmatically, but nothing in the manifest populates it — from the CLI's dev/serve
it's always empty. Embedding OpenStation as a library and calling wire({ toolSurfaces: [...] })
is the only way to reach it today.
What actually gates a tool
Three things mention tools. Only one enforces:
| Where | Effect |
|---|---|
.claude/settings.<label>.json |
the gate — Claude Code enforces it |
tools: in the charter |
shapes what the agent reaches for; not a boundary |
tools: in a profile |
an advisory pre-filter; not the enforced gate |
The old manifest permissions: field has been removed and is rejected at load time. Use
agent roles or inline allow/deny rules with generated gates,
or a hand-authored settings file in a workspace without a roles roster.
Both executors pass the turn's settings file to Claude with --setting-sources project,local, so
the workspace's own settings plus that file are the policy. The platform's own
allowed/disallowed list is passed too, but it's a pre-filter, not the decision. See
permissions.
Checklist for adding a tool
- Put the executable (or MCP server config) in the workspace.
- Add the narrowest grant that works. With a roles roster, edit the role or agent rules and
run
gates write; otherwise edit.claude/settings.<label>.jsondirectly. - Make sure the runtime is on the
PATHof the process runningdev/serve. - Mention the tool in the charter, or teach its usage in a skill.
- Run it and confirm — a grant that's too narrow shows up as the agent declining, not as an error you can grep for.
A second agent is also a kind of capability: see subagents for handing work
to one mid-turn, and what Task has to allow.