openstation

Guides

Tools and skills

Give an agent narrowly scoped command-line and MCP tools.

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

  1. Put the executable (or MCP server config) in the workspace.
  2. 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>.json directly.
  3. Make sure the runtime is on the PATH of the process running dev/serve.
  4. Mention the tool in the charter, or teach its usage in a skill.
  5. 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.

View Markdown source on GitHub ↗