openstation

Guides

Your first agent

Build a working agent one file at a time.

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 keeps okf/index.md current
  • 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

View Markdown source on GitHub ↗