Files
platform/docs/working-on-officer.md
T
pastilhasandClaude Opus 5 f1cfc0042f rename officer-agent to officer-claude-code
The old name said nothing about what the process runs, and it sits directly
beside officer-anthropic-proxy — a different process doing a different job — so
"the agent" was ambiguous exactly where it mattered. CLAUDE.md already had to
spend a paragraph insisting the two are not the same thing. It spawns `claude`;
the name says so now.

Only two references were functional: the generator's CORE_PROCESSES and the CORE
list in catalogue.test.ts. Everything else was prose or comments.

Left alone deliberately: `x-officer-agent-token`. It looks like the same string
and is not — it is the agent-handoff HTTP header, naming a per-panel bearer
token, unrelated to any pm2 process. Renaming it would have changed a wire
protocol to tidy a label.

Historical docs keep the old name. claude-sidecar-isolation.md and
open-threads-after-per-user-claude.md are dated investigations that record the
PREVIOUS rename, from officer-claude to officer-agent, and rewriting them would
make that history unreadable. CLAUDE.md notes the change instead, where somebody
reading those will be looking.

Also worth recording, from the owner: merging this with officer-anthropic-proxy
into one sidecar was investigated tonight and rejected. They stay separate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:10:39 +00:00

11 KiB

Working on Officer

The guide for anyone — human or agent — changing this deployment. It assumes you are working from the root of the install (the directory holding platform/, capabilities/ and data/), which is where agent sessions start.

Three directories sit there, and knowing which one a change belongs in is most of the job:

officer/
├── platform/       the application — a git repo
├── capabilities/   what the agent can do — a separate git repo
└── data/           runtime state — NOT version controlled

Officer is a self-hosted platform: an AI agent, a terminal, a file browser, a code editor, email, a bitcoin wallet, a remote desktop and dashboards, behind one web app. It is built around one owner — user id 1, role Super Admin, who bypasses every permission check — and since 2026-08-07 also admits additional accounts holding a strict subset of it, governed by per-role capability grants.

So "which user" has two answers depending on the surface. For the app capabilities (gitea, music, photos, email, calendar…) it is a real question with a real answer. For anything that executes code or touches the disk — terminal, chat, tasks, files, desktop, browser — it is still always the owner: those are kind: 'execution' in platform/src/servers/capabilities/registry.ts and can never be granted, because they run as the owner's OS user in the owner's home.

This paragraph said "there is no tenancy, no roles, no other users" until 2026-08-07. Four roles exist and five non-owner accounts are live; treat the capability registry as the source of truth over any prose, here or elsewhere.

platform/ and capabilities/ each have their own CLAUDE.md with detail. This file is the layer above them: where things live, how to change them safely, and the things that are true of the running system but written down nowhere else.


Which directory does this change belong in?

capabilities/ — almost always start here. Tasks, tools, skills, processes. It is data: plain directories of Markdown and scripts, read fresh on every request. Adding a task, changing what a task does, renaming a category — none of that needs a code change or a restart.

platform/ — only when the mechanism itself is missing. If a task needs a form control that doesn't exist, or an endpoint that isn't there, that's platform work. Adding a capability is not.

data/ — never edit by hand. DATA_PATH. Holds the owner's managed home, per-account email SQLite stores, job logs, the queue, sidecar state. It is not backed up by git; deleting things here destroys the only copy.

A useful test: would this differ between two Officer installs? Domain, paths, credentials → .env. Which tasks exist and what they're called → capabilities/. Everything else → platform/.

Git

Both repos are on master, both push to gitea.pastilhas.dev. There is one branch; per-instance differences live in .env, never in tracked files.

Commit and push both repos when a change spans them — a task usually pairs with the platform support it needs, and a half-pushed pair leaves the deployment inconsistent.

Keep history linear: git pull --rebase, not git merge. The remote moves — the owner develops on this box too — so expect to rebase before pushing. Say so before force-pushing anything.

Commit messages: simple lowercase, no prefixes, explaining why.


Running and checking your work

The server runs under pm2 as officer, plus sidecars (officer-anthropic-proxy, officer-claude-code, officer-opencode, officer-email, officer-pty, officer-vnc, officer-music, officer-vault, officer-slskd, officer-headscale, officer-transmission, officer-invoiceshelf, officer-wallet). pm2 list shows them; pm2 logs officer follows.

Two of those names are worth knowing apart: officer-anthropic-proxy holds the Anthropic credential and proxies API traffic; officer-claude-code is the process that actually runs claude.

Which process to restart. A change under src/servers/sidecar/<name>/ needs that sidecar restarted; a change anywhere else needs officer. Both, if you changed the wire between them. Restarting officer no longer costs you a running agent session or a terminal — every sidecar is a PM2 peer, and the terminal and email sidecars serve their own listeners, so officer is not in their data path at all.

Don't restart the owner's server to test. Boot your own on a spare port instead — the running instance holds PORT from .env (9010):

cd platform && PORT=59999 bun --env-file=.env src/server.tsx

Before claiming anything works:

cd platform
bunx tsgo --noEmit     # must be 0 errors — the tree is clean, keep it that way
bun test               # must stay green
bun format             # prettier, changed files only

Calling the API by hand

Protected routes validate the Host header against PUBLIC_URL, so a plain localhost request gets 403 Invalid origin. Send the real host explicitly:

TOKEN=$(bun --env-file=.env -e \
  'import { sign } from "./src/servers/jwt.ts"; console.log(await sign({ id: 1, email: "<owner>" }, "10m"))')
curl -H "Authorization: Bearer $TOKEN" -H "Host: rezio.pastilhas.dev" http://127.0.0.1:9010/api/tasks

This trips people up repeatedly. It is also why script tasks are handed OFFICER_API_HOST.

Services this box depends on

port what used by
9010 Officer itself
9002 Kokoro TTS text-to-speech
8178 whisper.cpp transcription
1234 vision model (Qwen2.5-VL) OCR
5432 Postgres the database

URLs live in Settings → System, stored in the server_config table — not in .env. If transcription or OCR fails, check the service is up before reading any code.


The task system

This is what most requests will be about. Tasks appear in the file browser's right-click menu under Run Task, grouped into submenus by category.

A task is a directory under capabilities/tasks/<slug>/ with a TASK.md — frontmatter plus a body — and, for script mode, a sibling run.sh / run.py / index.ts. The directory name is the task's identity; renaming it breaks every reference to it.

capabilities/CLAUDE.md documents the format. It is accurate but incomplete — the following are used heavily by real tasks and appear nowhere in it:

convention what it does
category: Video which submenu the task appears in. Order comes from capabilities/categories.yaml; an unlisted category still works, sorting after the listed ones. A category with no tasks never renders.
inline: true runs ephemerally in the modal instead of becoming a job
inline: ask offers both — Run here and Run as job
INPUT_INCLUDE newline-separated paths, injected by the modal on a multi-selection. The single most used input in the library — a task that ignores it silently processes the whole folder instead of the selection.
INPUT_GROUP_CONFIG per-track-layout config for folder-wide video runs
input types audio_tracks, subtitle_tracks, subtitle_edit render real track pickers, driven by probing the file
config.perGroupTracks, config.folderKeepAll, config.perGroupAllFiles opt into the folder track-picker behaviour — on script tasks, though the docs describe config as pipeline-only

Inputs reach a script twice: positionally in args order, and as INPUT_<NAME> environment variables.

Writing a task: copy an existing one

convert-audio is the plain template. transcribe-audio shows calling Officer's own API. extract-audio shows handling multiple streams. optimize-size shows the INPUT_INCLUDE helper.

Two ways a task does work:

  1. Run a tool directly (ffmpeg, jq, …). Right when the work is just a command.
  2. Call Officer's API, when the logic already exists server-side — transcription and OCR do this, so language detection, model config and output conventions stay in one place. Script tasks are given OFFICER_API_URL, OFFICER_API_HOST and OFFICER_AUTH_TOKEN for exactly this.

House style for file-processing tasks, worth keeping consistent:

  • Never delete or modify the source; write output beside it.
  • Handle a single file and a directory, recursively.
  • Honour INPUT_INCLUDE.
  • No caching. Re-running redoes the work and overwrites — and say so in the body, because it also overwrites edits.
  • End with a summary line and a non-zero exit if anything failed.

Debugging "my task didn't run"

  1. Does TASK.md parse? bun -e with getTaskByDirName from platform/src/servers/api/tasks/task-files.ts.
  2. Does the trigger match? Extension matching is on the lowercased final extension only — no globs, no MIME.
  3. Is the script executable, and does bash -n run.sh pass?
  4. Run it directly with INPUT_* set, before blaming the platform.

Things that will bite you

  • The vision model hallucinates on textless images. A blank white PNG came back as "The quick brown fox jumps over the lazy dog." OCR output on an image with no text is invented, confidently. Never point OCR at a photo library.
  • Whisper's translate mode only outputs English. It cannot translate into any other language. Transcription is therefore always in the source language; translating would mean a second pass through an LLM.
  • Agents run unsandboxed as the owner, with --dangerously-skip-permissions. Deliberate — it is the owner's own machine. Don't add a jail without being asked. The old bwrap sandbox was removed.
  • Schema changes use bun db:push, not migrations. drizzle-kit migrate has never run here; there is no __drizzle_migrations table. The schema code is the source of truth.
  • PUBLIC_BUILD_ENV fails closed. Unset means hardened — origin checks, rate limits and password rules all on. Only an explicit dev/development relaxes them.
  • index.html is a template. It carries __PUBLIC_URL__ placeholders; scripts/gen-index.ts writes the real index.gen.html at predev/prestart. Edit the template, then re-run bun gen:index--watch will not do it for you.

Verify, don't assume

Much of what is written down about this codebase has drifted from what it does. Several bugs found recently were invisible in the code and obvious the moment something was actually run — a type assertion that lied about a third-party response, a language detector whose result was computed and then discarded, a menu entry pointing at a deleted screen.

Read the code, then run it. When reporting, say what you actually checked and what you didn't.