The scaffolded agent from getting started can read anything and write
only under okf/. This guide builds one that does more: an agent that keeps a written record
of decisions under its own directory, commits every change, and reaches a Python tool from the
TypeScript platform.
The finished version is examples/notes-agent — runnable, and
covered by a test so it can't rot. Build it here to understand why each file exists, then use
the example as the reference.
What you're building
An agent that:
- files notes under
okf/issues/and keepsokf/index.mdcurrent - searches existing notes before answering
- may write only in the artifact zone — everything else is read-only
- refuses to read
.env, and says why - counts notes with a Python script, from a Bun platform
Prerequisites are the same as getting started: Bun, an authenticated Claude Code CLI. No connector credentials.
1. A workspace that's a repository
bun /path/to/openstation/packages/cli/src/index.ts create notes
create notes scaffolds ~/openstation/notes/ — the default home for workspaces, one
git repo per agent project — names the agent after it, and initializes the repository with the
scaffold as its first commit. -d <dir> scaffolds into a directory you already have instead.
That repository is not optional decoration. Reversibility is the reason to keep an agent's work in
a workspace instead of a chat log, and that only works if there's a repo — so if create reports
that git couldn't run, fix that before the agent writes anything.
2. The manifest: manage, don't describe
.openstation/openstation.yaml:
agents:
notes:
identity: agent:notes
executor: claude-cli
claudeSettings: notes # -> .claude/settings.notes.json (the ENFORCED tool gate)
channels: [] # add a channel name to route it there
default: true # answers anything no other agent claims
permissions: [workspace.write]
Six fields, none of them behavior. identity attributes work in the audit trail, executor
picks the runtime, settings names the file that enforces permissions, default: true makes
this agent answer when nothing else claims the channel — and dev needs that to boot at all.
permissions: [workspace.write] is a label for humans. It grants nothing. If you delete it
the agent behaves identically; the actual grant is in step 4.
Try adding model: claude-opus-4-8 here and loading it. It fails:
agents.notes: Unrecognized key(s) in object: 'model'
That's the design working. Behavior has exactly one home, and it isn't this file.
3. The charter: what the agent is
.claude/agents/notes.md — a Claude Code agent file. Claude reads this; OpenStation only checks
that it exists.
---
name: notes
description: Keeps a durable, searchable record of decisions and open questions.
tools: Read, Glob, Grep, Skill, Edit, Bash
---
You keep this workspace's written record.
When someone tells you something worth remembering — a decision, a problem, an open
question — write it down as a note under `okf/issues/` using the `note` skill, then say what
you filed and where. When someone asks a question, search the existing notes first and
answer from them, citing the file you used.
You may read anything in the workspace and write only under `okf/`.
Never invent a fact to fill a gap: if the notes don't say, say they don't say.
Two things worth noticing.
tools: here is a comma-separated string — Claude's dialect. The profile file in step 5 uses
a YAML array for the same concept. They are not interchangeable.
This tools: line is not the security boundary. It shapes what the agent reaches for; the
settings file decides what it's allowed to reach. Saying "write only under okf/" in the
prompt is helpful, but the prompt isn't what stops it.
4. The settings file: the actual gate
.claude/settings.notes.json:
{
"permissions": {
"allow": [
"Read",
"Glob",
"Grep",
"Skill",
"Edit(okf/**)",
"Write(okf/**)",
"Bash(python3 tools/stats.py:*)"
],
"deny": [
"Read(.env)",
"Read(**/.env)",
"Read(.env.keys)",
"Read(**/.env.keys)",
"Read(**/secrets/**)",
"Bash(rm:*)",
"Read(var/**)",
"Bash(cat var/*)",
"Read(logs/**)",
"Bash(cat logs/*)"
]
}
}
Read the allow list as the whole story of what this agent can do. Writes are scoped to the
okf/ bundle, not a blanket Edit. The one Bash grant names a single script — not python3, and
certainly not Bash. var/ is denied so the agent can't read the platform's own state about
itself.
This file is loaded by Claude Code, bound to the agent by the manifest's claudeSettings: label. See
permissions for how that binding works and what it does and doesn't guarantee.
5. The profile: who's asking
.openstation/profiles/member.md:
---
tools: ["Read", "Glob", "Grep", "Skill", "Edit", "Bash"]
---
You are talking to a teammate. Be brief. Prefer filing a note over a long reply — a
sentence saying what you filed and where beats three paragraphs they have to re-read.
The charter is what the agent is; the profile is what changes by who is talking. Note the YAML array — the dialect difference from step 3.
Roles come from the roster, or from --as when there is none, defaulting to member. A role with no profile file is refused
rather than downgraded, because a missing profile means nobody decided what that role may do.
6. The skill: a procedure, taught once
.claude/skills/note/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:
---
type: note | decision | question
created: YYYY-MM-DD
status: open | resolved
---
Rules that matter:
- **Update, don't duplicate.** Search `okf/issues/` first; if a note covers the subject, edit it.
- **A decision records its alternatives.** A decision note without the option that lost is
not useful six months later.
- Keep `okf/index.md` current (not `okf/issues/index.md`): one line per note, newest first.
Claude discovers skills on its own — OpenStation never reads this file. Put the format here and keep the charter about judgment; that way the format can change without touching the charter.
Be specific about paths. An earlier version of this skill said "keep index.md current" and the
agent created okf/issues/index.md, which is not what was meant.
7. A tool in another language
tools/stats.py:
#!/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")
...
The full script is in the example. The platform is
TypeScript; this is Python; Claude runs it as a subprocess under the single grant
Bash(python3 tools/stats.py:*). That's the whole any-language story — see
giving agents tools for MCP servers and workspace runtimes.
8. Run it
bun /path/to/openstation/packages/cli/src/index.ts notes dev
Ask it to record a decision:
you> we chose interval schedules over cron because resumability matters more than
expressiveness — write that down
A real run of this produced okf/issues/interval-schedules-over-cron.md, including the alternative
that lost, because the skill demands it:
---
type: decision
created: 2026-07-25
status: resolved
---
# Interval schedules over cron
Schedules are expressed as intervals ("every 5m") rather than cron expressions.
## Why it matters
Resumability matters more to us than expressiveness. An interval is relative to the last
run, so a schedule that was paused, killed, or missed can pick up from where it stopped…
## Alternatives considered
- **Cron expressions.** More expressive: calendar-aligned schedules ("09:00 on weekdays")
that intervals can't express at all. Rejected because absolute timing makes
resume-after-gap ambiguous, which is the case we actually hit.
It also added a line to okf/index.md, and it reported back what it filed and where.
9. Check the three things worth believing
The work is a commit — everything git tracks, minus the platform's own var/.
$ git show --stat HEAD
okf/index.md | 1 +
okf/issues/interval-schedules-over-cron.md | 28 ++++++++++++++++++++++++++++
2 files changed, 29 insertions(+)
Two files, both inside the grant. The event log was written to var/events/ during the same
turn and isn't in the commit. git revert HEAD undoes the agent's work.
The gate is Claude's, not a filter of ours. Ask it to read .env and it refuses, citing the
settings file — the refusal comes from inside Claude's decision loop, not from a platform
pre-filter that stripped a tool from a list.
The Python tool runs. Ask it to run the stats tool; it invokes python3 tools/stats.py and
reports the counts.
The honest caveat about that gate
Enforcement lives inside Claude's decision loop, which makes a deny entry a strong default,
not a hard boundary. It's the right default for the vast majority of what an agent does — and
for anything that must never happen, put something underneath it: a PreToolUse hook, or OS
level permissions on the process.
This is worth internalizing before you widen an allow list. The notes agent reached this conclusion on its own during a live run and filed it as a note.
Next
- Giving agents tools — CLI tools, MCP servers, workspace runtimes
- Permissions — the enforcement model in full
- Connectors — make it reachable by other people
examples/notes-agent— the finished version, with its own README