micro coding agent harness vibecoded in golang
  • Go 99.8%
  • Shell 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Rafael Stauffer 486d16449c
All checks were successful
CI / test (push) Successful in 1m39s
Images / images (push) Successful in 2m14s
CI / build (push) Successful in 1m25s
CI / release (push) Has been skipped
feat(tools): add todo list, workdir-scoped delete and move
2026-10-31 15:58:59 +01:00
.forgejo/workflows Serialize CI and Images runs for the same commit 2026-09-16 20:43:21 +02:00
.harness fix(spawn): pass the spawning console's context length to spawned sessions 2026-09-17 10:44:23 +00:00
charts/harness-agent feat(tools): add todo list, workdir-scoped delete and move 2026-10-31 15:58:59 +01:00
cmd/harness-controller Add hardening and a k3d end-to-end test (Phase 7) 2026-09-13 18:07:27 +02:00
deploy wip fix labeling for argo 2026-09-14 20:53:33 +02:00
docs feat(spawn): apply an Ansible setup playbook in the session container 2026-09-17 17:02:00 +02:00
examples/commands feat(commands): add latex and beamer command file 2026-10-31 14:40:32 +01:00
internal feat(tools): add todo list, workdir-scoped delete and move 2026-10-31 15:58:59 +01:00
scripts feat(spawn): apply an Ansible setup playbook in the session container 2026-09-17 17:02:00 +02:00
test/e2e fix(spawn): pass the spawning console's context length to spawned sessions 2026-09-17 10:44:23 +00:00
.gitignore chore: track the .harness documentation store in git 2026-09-16 22:42:10 +02:00
Dockerfile.agent feat(spawn): apply an Ansible setup playbook in the session container 2026-09-17 17:02:00 +02:00
Dockerfile.controller Fully qualify base images so buildah resolves them 2026-09-13 18:26:42 +02:00
go.mod Add documented read/write tools backed by a bbolt doc store 2026-09-16 20:38:08 +02:00
go.sum Add documented read/write tools backed by a bbolt doc store 2026-09-16 20:38:08 +02:00
main.go feat(tools): add todo list, workdir-scoped delete and move 2026-10-31 15:58:59 +01:00
main_test.go feat(tools): add todo list, workdir-scoped delete and move 2026-10-31 15:58:59 +01:00
README.md feat(tools): add todo list, workdir-scoped delete and move 2026-10-31 15:58:59 +01:00

Harness

A super minimal coding agent harness written in Go, presented as a terminal UI.

Harness connects an LLM to your workspace and gives it a small set of tools to read, search, edit, and run things. It speaks the OpenAI-compatible /chat/completions API, so it works with a local llama.cpp server as well as hosted APIs such as z.ai, Mistral, and DeepSeek.

Goal

Provide a tiny, hackable alternative to heavyweight coding agents:

  • A single Go binary with no runtime dependencies.
  • One terminal UI with a scrollable output pane and a bordered input console.
  • A small, well-defined tool set the model can call.
  • First-class support for local models (llama.cpp), including native function calling and a fallback XML tool-call format for models that need it.
  • Explicit, per-tool approval before anything mutates your files.

Requirements

  • Go 1.24 or newer (to build from source).
  • A model endpoint. Either a local llama.cpp server or an API key for one of the supported providers.

Build

git clone <repo>
cd harness
go build -o harness .

Usage

./harness [flags]

On first run Harness creates ~/.config/harness/config.yaml and ~/.config/harness/systemprompt.md if they do not exist, then starts the TUI in the current working directory.

Flags

Flag Description
--config Path to the config file (default ~/.config/harness/config.yaml).
--workdir Workspace directory the agent operates in (default: launch dir).
--provider Provider to use, overriding the config.
--model Model to use, overriding the config.
--commands Extra command file(s) to load, by name (see Command files). Repeatable and comma-separated.
--remote Connect to a remote agentd over a WebSocket (ws:///wss://) instead of running an agent locally (see Remote agents).
--controller Open the fleet view for the given controller URL (http:///https://), listing its sessions and attaching on demand (see Command center). The flag requires a URL; run harness controller to use HARNESS_CONTROLLER_URL or controller_url from the config.
--token Shared secret presented to a remote agentd or controller. In controller mode it falls back to HARNESS_CONTROLLER_TOKEN, then HARNESS_TOKEN, then controller_token; for --remote it is read from HARNESS_TOKEN.
--export-docs Export the documentation database to a YAML file and exit (see Documented tools).
--version Print the version and exit.

The controller, spawn, fetch, and rm subcommands accept the same --controller, --token, and --config flags and use the same fallbacks.

Keyboard

Key Action
Enter Submit the prompt (input) / collapse-expand the selected diff (output). While the agent is working, submitting queues a steering message instead (see Live steering).
Tab Accept a slash-command suggestion. With tab_switches_focus: true it otherwise moves focus between the input and the output pane.
Esc Cancel a running request or an in-progress compaction, refocus the input, or deny an approval.
↑ / ↓, PgUp/PgDn Scroll the focused pane.
[ / ] Select the previous/next file diff (output focused).
Ctrl+C Quit.

Mouse (left button):

Action Effect
Click a tool block's border Collapse or expand that block (diff or output). Any part of the border — top title, bottom, or either vertical edge — works.
Click the autofollow indicator Re-activate auto-follow after scrolling up (while paused, the indicator between output and input reads ▼ autofollow paused).
Click the prompt input area Focus the input and place the cursor at the clicked position.
Mouse wheel Scroll the output (or the approval diff).

Slash commands

Type / to start a command; an inline ghost-text suggestion completes the command name as you type. Press Tab to accept the suggestion (otherwise Tab switches focus when tab_switches_focus: true; it does nothing by default). Set ghost_text: false in the config to disable the suggestion.

Command Description
/provider <name> Switch the active provider.
/model <name> Switch the model for this session.
/compact Summarize the conversation to free context.
/clear Clear the conversation history.
/detach Leave the attached session and return to the controller's fleet view (see Command center). In local or direct-remote mode it reports that there is nothing to detach from.
/help Show the available commands.
/quit Quit.

Prompt input

The input console stays editable while the agent works. Long prompts wrap onto as many rows as needed instead of being cut off at the pane's right edge, both while you type and in the transcript, so the whole message stays visible.

Live steering

You do not have to wait for the agent to finish. While a turn is running the prompt stays editable: type another message and press Enter to steer. The message is queued and appended to the conversation as a new user turn at the next model iteration — after the current streamed response and any tool calls finish — so the agent sees your correction mid-run rather than after the fact. A queued message is tagged · queued in the transcript until it is delivered.

The prompt console makes the run state visible: a spinner sits next to the prompt, and a ghost hint line appears under the input reading agent is working — press Esc to interrupt, so the interruption key is always at hand.

If a message is queued just as the agent is wrapping up, the turn is kept alive to answer it, so the input is never dropped. Messages submitted in the same breath are delivered together, in order. Esc still cancels the turn; anything still queued at that point is discarded. Submitting more input while idle starts a normal new turn.

Approvals

Tools that change the workspace (bash, write, edit, delete, move) require confirmation by default. The prompt shows [y] approve / [n] deny; for write and edit it renders the planned change as a side-by-side diff before you decide. A write that creates a file has nothing to diff against, so it shows the planned file data alone as a single, line-numbered column. For custom commands it shows a structured, multiline breakdown — the shell command that will run and each parameter on its own line — so you can check exactly what is executed and with which inputs. For other tools the full command and its arguments are shown wrapped onto as many lines as needed, so nothing is cut off. Read-only tools (read, glob, grep, webfetch, todo, the read-only forgejo_* tools, and — when documented, readdoc and readast) run without a prompt.

The prompt window grows to show the whole approval at once, shrinking the output pane above it, and drops back to its normal size as soon as you approve or deny. The approval is shown whole: it never scrolls. An approval taller than the terminal overflows above it, so the terminal keeps the newest lines — the end of the command and the prompt — in view.

Each tool has a tristate approval mode:

Mode Behavior
always Prompt before every call.
never Run without prompting.
workdir Auto-approve when the target is inside the workspace, prompt otherwise.

workdir only auto-approves tools that can locate their target (read, write, edit, delete, move, glob, grep). bash can escape the workspace, so it still prompts in workdir mode. The old boolean values are accepted too (true = always, false = never). Custom commands (see Custom commands) default to always; in workdir mode they are auto-approved only when every path parameter stays inside the workspace.

Remote sessions

A session spawned on a cluster runs away from the console that created it, so it is gated by a separate remote-tools block instead of tools. It has the same structure, and when set it replaces tools for that session: a tool it does not name falls back to its built-in default (prompt for bash/write/edit, no prompt for the read-only tools). Leaving the block out keeps tools, so nothing changes for a session that does not need a different policy.

tools:
  bash: { approval: always }
remote-tools:
  bash:  { approval: never }
  write: { approval: always }
  edit:  { approval: always }

The block is sent with the create request, so the session enforces the policy of the console that spawned it: the controller renders it into a harness-<session>-config ConfigMap that the pod passes to agentd as --config. Custom commands are not part of it — they carry their own approval and are the same locally and remotely.

The spawning console also sends its max_iterations with the create request, so a spawned session stops at the same iteration cap the user configured locally. The controller passes it to the session's agentd as --max-iterations (on both the process and cluster paths); a request without it leaves the session on its own config/default.

It sends the provider's effective context window the same way, passed to the session's agentd as --context-length, so the session's context meter shows the real limit the console displays instead of the agent image's default for that provider (for example a DeepSeek session configured for 1M tokens no longer reports the built-in 64k). When context_length is 0 (auto-detect) for a local provider, the console resolves the detected value first and sends that, so a spawned session inherits the same limit even when the console relies on auto-detection. A request that carries no value leaves the session on its own config/default.

Disabling tools

Set disabled: true on a tool to switch it off entirely: it is removed from the model's tool list and any call to it is rejected as if it did not exist, without deleting its configuration. This is handy for turning off a powerful built-in such as bash in a context that does not need it, which also keeps it out of the prompt and saves context:

tools:
  bash: { disabled: true }

Custom commands take the same disabled field, so a command loaded from a command file can be defined ahead of time and enabled only when needed.

Tool output

Each tool's result is shown as a collapsible block: a bordered box whose top border is titled with the command. write and edit show their change as a side-by-side diff inside the same box; a write that creates a file shows the planned file data alone, since there is nothing to diff against. Other tools show their output text. The command and its arguments are always shown in full: when they are too long for one line they wrap onto additional lines instead of being cut off. Output text wraps to the pane as well — any line wider than the window, including a long unbreakable token such as a path, URL, or hash, wraps onto additional rows rather than being cut off at the right edge. Collapsed, the block is just the title line. The border and collapse behavior are the same for every tool — click anywhere on a block's border (top title, bottom, or either vertical edge) to collapse or expand it, which is handy for minimizing an output that spans multiple screens. Set collapsed: true on a tool to start its output collapsed:

tools:
  read: { approval: never, collapsed: true }

Auto-follow

New output is followed automatically: the pane stays pinned to the newest messages so the agent's latest words are always in focus, with a ▼ autofollow indicator shown between the output and the prompt. Scroll up and auto-follow unsticks, leaving the pane exactly where you left it (the indicator then reads paused) so you can read without being pulled away by incoming updates. Scroll back down to the end and it re-arms.

Remote agents

The same binary can run its agent as a service and be driven from another console over a WebSocket. This is the first step toward running many agents on a cluster; the local, in-process mode above stays the default and is unchanged.

Start an agent server:

harness agentd --listen :8080 --workdir /path/to/workspace --session my-task --token "$HARNESS_TOKEN"

Attach a console to it:

harness --remote ws://127.0.0.1:8080/session --token "$HARNESS_TOKEN"

The remote console is the same TUI with the same behaviour: streamed replies, tool blocks, approvals (y/n), live steering, Esc to abort a running turn, and /compact. agentd also serves /healthz for liveness. When --token is empty on the server, authentication is disabled.

agentd runs the same agent loop, tools and providers as the local mode; only the transport differs. The console and the server speak a small JSON message protocol (see internal/wire), so an agent can run in one process (or, later, one pod) and be driven from another.

Command center

harness-controller is a small service that fronts several agentd sessions and gives the console a fleet view over them. It is a separate binary so it can grow to create and manage session pods (later phases) without adding dependencies to the local tool.

Configure the sessions it fronts:

# controller.yaml
listen: ":8090"
token: "${HARNESS_CONTROLLER_TOKEN}"   # optional console secret
sessions:
  - id: external
    name: "Existing agent"
    url: ws://127.0.0.1:8080/session   # an agentd already running
    token: "${HARNESS_TOKEN}"          # presented to that agentd
  - id: worker
    name: "Supervised worker"
    url: ws://127.0.0.1:8081/session
    command: ["harness", "agentd", "--listen", "127.0.0.1:8081", "--workdir", "/srv/work"]

Run it:

harness-controller --config controller.yaml

Sessions with a command are supervised: the controller starts the process and kill stops it — the pre-Kubernetes stand-in for a session pod. Sessions without one are external: the controller just fronts an agentd that runs elsewhere and probes it for reachability.

Endpoints:

Endpoint Description
GET /healthz Liveness.
GET /api/v1/sessions JSON list of sessions with their status (available, starting, unreachable, stopped, exited).
POST /api/v1/sessions Create (spawn) a session from a CreateSpec (see Workspace sources).
POST /api/v1/sessions/{id}/kill Stop a session (terminate a supervised process, or tombstone an external one).
DELETE /api/v1/sessions/{id} Stop and remove a session and its workspace.
POST /api/v1/sessions/{id}/workspace Upload a tar as the session's workspace.
GET /api/v1/sessions/{id}/workspace Export the session's workspace as a tar.
GET /api/v1/sessions/{id}/attach WebSocket: a console attaches here and is relayed to the session's agent.

The controller can also create sessions: configure workspace_root, harness_binary, agent_config (and optionally agent_token), and a POST /api/v1/sessions spawns a supervised harness agentd for the requested workspace. See Workspace sources.

Open the fleet view:

# with controller_url/controller_token set in the config, use the controller
# subcommand (plain `harness` always stays local)
harness controller
# or name the controller explicitly, on any host:
harness --controller http://127.0.0.1:8090 --token "$HARNESS_CONTROLLER_TOKEN"

The default harness command always runs a local agent in the workdir, even when controller_url or HARNESS_CONTROLLER_URL is set. Reaching a controller takes an explicit harness controller (or --controller <url>), so a controller configured for the fleet view cannot hijack normal local use.

The fleet view lists the sessions and their status. ↑/↓ move, Enter attaches, d stops the selected session, r refreshes, and q quits. Attaching drops into the ordinary console — same transcript, tool blocks, approvals, live steering and Esc-to-abort — driven through the controller's relay. Type /detach to return to the fleet list. --remote remains available for attaching straight to a single agentd without a controller.

Workspace sources

A session's working directory can be cloned from a git repository or copied from a local folder. The controller provisions it when the session is created, and agentd serves it at /workspace for upload and export.

Get a workspace from a repository:

harness spawn --repo https://git.pr0.tech/rs/harness.git --ref main

Or upload a local folder (small/medium trees; the git mode is preferred for anything large):

harness spawn --local ./mydir --exclude '*.log,node_modules'

spawn reaches the controller named by controller_url/HARNESS_CONTROLLER_URL (or --controller). It creates the session, waits for the agent to come up, uploads the folder in --local mode, and attaches the console. Add --attach=false to just create the session. Useful flags: --name, --provider, --model, --label key=value (repeatable), --path (a subdirectory of the repo to use as the root), --git-token-env (the environment variable holding a token for a private repo; its value never reaches the command line), and --setup (an Ansible playbook applied in the session container, see Custom session tooling).

Custom session tooling

A session container ships with only the basics (shell, git, ripgrep, ssh), so a Go project has no gofmt and a Python project has no python. spawn --setup takes a local Ansible playbook and applies it inside the session container as root before the agent starts, so the agent user inherits the installed tools and environment:

cat > tools.yml <<'EOF'
---
- hosts: localhost
  tasks:
    - name: install Go tooling
      apt:
        name: [golang-go, golang-golang-x-tools]
        state: present
        update_cache: true
EOF

harness spawn --repo https://git.pr0.tech/rs/myproject.git --setup tools.yml

The playbook is sent with the create request, stored in a per-session ConfigMap and mounted read-only; the agent image's entrypoint runs ansible-playbook against localhost as root, then drops to the unprivileged agent user (uid 10001) for the agent process. Setup requires the cluster (kube) backend: the local process backend has no container to configure and rejects the flag. Because the container must start as root to install packages, a session with a setup playbook runs with a relaxed security context (no runAsNonRoot, no capability drop) for the setup step; without --setup the hardened non-root context is unchanged. A playbook is code, not a secret — keep credentials in the provider/git secrets.

Read a session's workspace back:

harness fetch --id <id> --to ./out

Delete a session and its workspace:

harness rm --id <id>

All three accept --controller, --token, and --config to override the config for a single call.

Notes:

  • git mode clones at agentd startup with a shallow fetch of the requested ref (branch, tag, or commit) and checks it out; --path lifts a subdirectory to the workspace root. An existing non-empty workspace is left untouched, so a restart does not re-clone. Private repositories use a token: pass it in HARNESS_GIT_TOKEN (or the variable named by --git-token-env); it is used as the password with any username and is never logged.
  • local mode tars the chosen directory (honoring --exclude and any .harnessignore) and streams it to the session. Uploads are capped at 64 MiB.
  • Both harness agentd and the controller accept workspace flags; the controller builds the agentd command for a spawned session from its CreateSpec.

Run agentd directly with a workspace source (without a controller):

harness agentd --listen :8080 --workdir /srv/work \
  --workspace-mode git --git-url https://git.pr0.tech/rs/harness.git --git-ref main
harness agentd --listen :8080 --workdir /srv/work --workspace-mode local
# then: curl -X POST --data-binary @- http://localhost:8080/workspace < tree.tar

Running on Kubernetes

The controller can run in a cluster and create each session as its own pod (a Deployment + Service), with everything in a single harness namespace. The console still drives them through the same fleet/attach flow.

Set the controller's backend to kube:

# controller.yaml
listen: ":8090"
token: "${HARNESS_CONTROLLER_TOKEN}"
backend: kube
kube:
  namespace: harness
  image: git.pr0.tech/rs/harness-agent:latest
  provider_secret: harness-provider-keys
  service_account: harness-agent
  port: 8080
  prometheus: true
agent_token: "${HARNESS_AGENT_TOKEN}"

Sessions created through the controller (for example harness spawn --repo …) become a Deployment, Service and auth Secret named harness-<session> in the namespace; the console attaches to the session's Service and drives it exactly as a local agent.

A spawned session also inherits the spawning console's provider and model (an explicit --provider/--model flag, else provider and the provider's model or the global model), its context window, its iteration cap, and its documented_tools/references settings, so it runs the same model, context limit and tool variants as the console that created it rather than the agent image's built-in defaults. When the console has a remote-tools block (see Remote sessions) or documented tools enabled, the controller renders these into a harness-<session>-config ConfigMap that the pod reads as its config, so the session's approval policy and read/write tools are the ones it was spawned with.

Provider API keys (Secrets)

Provider keys live in a Kubernetes Secret whose keys are named to match Harness's existing environment override — HARNESS_<PROVIDER>_API_KEY — and are injected into every session pod via envFrom. Create it out of band so it never enters Git (ArgoCD never sees it):

kubectl -n harness create secret generic harness-provider-keys \
  --from-literal=HARNESS_DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY"

The controller's own console token and the shared agent token are read from the harness-controller-token and harness-agent-token Secrets (token key each). A private git clone uses HARNESS_GIT_TOKEN from the per-session auth Secret.

Deploying

deploy/ holds kustomize manifests for the static platform (namespace, RBAC, the controller) and an ArgoCD Application/AppProject:

kubectl apply -k deploy/overlays/dev          # or: argocd app sync harness

The single ArgoCD Application harness deploys the platform and shows the whole namespace in its resource tree — including the session pods the controller creates. The controller stamps every session resource (and its pod template) with the ArgoCD tracking annotation argocd.argoproj.io/tracking-id for the harness Application, which is what ArgoCD's default annotation tracking method matches on, and with the tracking label app.kubernetes.io/instance=harness (so it also works under label tracking). The argocd.argoproj.io/* sync/compare annotations keep ArgoCD from pruning or flagging the out-of-band resources. Open the harness Application in ArgoCD to overview every active agent.

charts/harness-agent renders the same per-session shape for a hand-managed session.

Metrics

Each agent and the controller expose Prometheus metrics at /metrics — agent turns, iterations, tool calls and durations, token usage, context pressure, model request latency and errors, plus session phases and relayed-frame counts on the controller. Agent pods and the controller Service carry Prometheus scrape annotations, and an optional ServiceMonitor is provided for a kube-prometheus-stack. Your existing Prometheus scrapes them; Harness installs nothing. See docs/observability.md for the metric reference and scrape configuration.

Run agentd or the controller and curl localhost:<port>/metrics to see them.

Security and hardening

  • Tokens. A console presents a shared secret to agentd (in its hello and on the workspace endpoints) and to the controller (as a bearer token and in its attach hello). Comparisons are constant-time, and a rejected token is audited.
  • Network policies. deploy/base/networkpolicy/ restricts agent pods to DNS and outbound HTTPS plus ingress from the controller and the scraper, and the controller to DNS, session endpoints and the API server. The controller's API egress allows both the k3s service ClusterIP port (443) and the node's API port (6443), because k3s DNATs the former to the latter before the egress policy is enforced; the real overlay narrows that rule to your service and node CIDRs with an ipBlock. Tighten the other egress blocks to your model/git CIDRs in production.
  • Sandboxing. Session pods run as non-root, without a ServiceAccount token, with dropped capabilities and bounded CPU/memory (defaults applied by the controller; override in the kube settings). The controller runs read-only.
  • Audit logging. agentd and the controller write security-relevant events as JSON lines (one object per line) — approval requests and decisions (with a hash of the tool arguments, never the arguments themselves), session create/delete/kill/attach, and auth failures. Point it at a file with --audit-log <path> (default stderr; off disables; also HARNESS_AUDIT_LOG).
  • Approvals. A remote session gates mutations on the console by default; autonomous sessions should use never/workdir only with the NetworkPolicy and ServiceAccount above.

Agent groups (multi-agent)

Agents that share a group can coordinate through the controller. When an agent is started with a group and the controller's URL, Harness adds two tools to its tool set:

  • list_agents — list the other agents in the group and their status.
  • message_agent{to, text} — send a short message to a peer, or to the whole group when to is empty.

A delivered message reaches the target agent as work: if the target is mid-turn it is injected as a steering interjection (arriving like a human interjection); if it is idle it starts a fresh turn. So grouped agents can hand work to each other and cooperate on their workspace.

Enable it on an agent (the controller passes these for sessions it spawns):

harness agentd --listen :8080 --workdir /srv/work --session reviewer \
  --group test-fixers --controller-url http://harness-controller.harness.svc:8090

The two tools are registered only when --controller-url is set (or HARNESS_CONTROLLER_URL), so a plain local agent is unchanged. A session's group comes from --group (or HARNESS_GROUP); when the controller creates a session, the group label on the session is used. The controller's /api/v1/agents and /api/v1/agents/message endpoints back the tools, and the fleet view shows each session's group in its labels.

Configuration

Configuration lives in ~/.config/harness/:

  • config.yaml — providers, tool behavior, custom commands, and the UI theme.
  • systemprompt.md — the global system prompt sent with every request.
  • additional *.yaml command files — optional, loaded on demand (see Command files).

Values may reference environment variables with ${VAR}, and API keys can be overridden with HARNESS_<PROVIDER>_API_KEY (for example HARNESS_DEEPSEEK_API_KEY).

controller_url and controller_token let harness controller and the spawn/fetch/rm commands reach a controller without repeating --controller/--token:

Field Description
controller_url Controller base URL (http:///https://). Overridden by HARNESS_CONTROLLER_URL, then --controller.
controller_token Console secret presented to the controller. Overridden by HARNESS_CONTROLLER_TOKEN (then HARNESS_TOKEN), then --token. Prefer a ${…} reference so the secret is not stored here.

The precedence is always explicit flag, then environment, then this file. The agent-facing token used by --remote and agentd --token is a separate secret and is not read from these fields.

Example

provider: deepseek
# model: deepseek-chat
# workdir: /path/to/workspace
# controller for `harness controller` and spawn/fetch/rm (overridable from the env)
# controller_url: https://harness.maus.local
# controller_token: ${HARNESS_CONTROLLER_TOKEN}
# inline ghost-text completions for slash commands
ghost_text: true
# let Tab move focus between the prompt input and the output pane
tab_switches_focus: false
# cap how many model iterations a single turn may run before it is stopped
max_iterations: 100
# automatically compact the conversation when the context reaches this many
# tokens; 0 disables automatic compaction
autocompact_limit: 0
# tune for a model with a small context window: derive the compaction threshold
# from the window and cap how much of one tool result enters the conversation
# small_context: false
# per-tool-result token cap (0 leaves it uncapped; small_context applies 2000)
# max_tool_result_tokens: 0
# replace read/write with documented variants backed by a documentation store
# documented_tools: false
# how documented reads find code references: ast (go/parser, default) | grep
# references: ast
# fail a documented write or edit when an exported Go symbol is undocumented
# require_docs: false
# keep the references section of a documented read small: when true (default)
# only the files referencing the read symbols are listed, not their contents
# read_doc_reference_minimal: true
# documentation database path (defaults to <workdir>/.harness/docs.db)
# docs_db: .harness/docs.db

# optional extra command files to load custom commands from (see Command files)
# command_files:
#   - golang

providers:
  llama:
    base_url: http://localhost:8080/v1
    api_key: ""
    model: local-model
    # native | xml | both
    tool_calls: xml
    # 0 = auto-detect from the server (llama.cpp /props)
    context_length: 0
  zai:
    base_url: https://api.z.ai/api/paas/v4
    api_key: ${ZAI_API_KEY}
    model: glm-4.5
    tool_calls: native
    context_length: 131072
  mistral:
    base_url: https://api.mistral.ai/v1
    api_key: ${MISTRAL_API_KEY}
    model: mistral-large-latest
    tool_calls: native
    context_length: 131072
  deepseek:
    base_url: https://api.deepseek.com/v1
    api_key: ${DEEPSEEK_API_KEY}
    model: deepseek-chat
    tool_calls: native
    context_length: 65536

# Per-tool approval. Modes: always | never | workdir.
# workdir auto-approves targets inside the workspace and prompts for others.
# collapsed: true starts the tool's output block collapsed.
# disabled: true hides the tool from the model and rejects calls to it.
tools:
  bash:     { approval: always }
  write:    { approval: always }
  edit:     { approval: always }
  delete:   { approval: always }
  move:     { approval: always }
  read:     { approval: never, collapsed: true }
  glob:     { approval: never }
  grep:     { approval: never }
  webfetch: { approval: never }
  todo:     { approval: never, collapsed: true }
  # readdoc, writedoc, deletedoc, and readast appear only when
  # documented_tools: true.
  readdoc:   { approval: never, collapsed: true }
  writedoc:  { approval: never, collapsed: true }
  deletedoc: { approval: never, collapsed: true }
  readast:   { approval: never, collapsed: true }

# Per-tool approval for supervised remote sessions (agent pods the controller
# creates, see "Remote sessions"). Same structure as tools; when set it replaces
# tools for those sessions, and a tool it does not name falls back to its
# default. Custom commands are never listed here: they keep their own approval.
# remote-tools:
#   bash:   { approval: never }
#   write:  { approval: always }
#   edit:   { approval: always }
#   delete: { approval: workdir }
#   move:   { approval: workdir }
#   read:   { approval: never }

# Custom shell commands exposed to the model as tools (see below).
commands:
  - name: deploy_staging
    description: Deploy the current branch to the staging environment.
    command: ./scripts/deploy.sh --ref "$HARNESS_ARG_ref"
    approval: always
    collapsed: true
    parameters:
      - name: ref
        description: Git ref to deploy.
        required: false
        default: HEAD

Documented tools

Setting documented_tools: true replaces the built-in read, write, and edit tools with variants that also read and maintain a dedicated documentation store and report Go code references. The tools keep the names read, write, and edit, so approvals, diffs, the read-before-write guard, and disabled/collapsed settings are unchanged. Everything below applies only when the flag is on.

Key Default Description
documented_tools false Swap read/write/edit for the documented variants and add the readdoc/writedoc/deletedoc/readast tools.
references ast How documented reads and readast find references: ast (go/parser, receiver-qualified for methods) or grep (word-boundary textual search).
require_docs false When true, a documented write or edit is refused if an exported Go symbol has no supplied or stored documentation. When false undocumented symbols are reported as a warning.
read_doc_reference_minimal true When true (the default), the references section of a documented read lists only the paths of the files that reference any of the read symbols, keeping the output small. Set to false for the full per-line definition/usage listing.
docs_db <workdir>/.harness/docs.db Documentation database path. An absolute path is used as-is; a relative one is resolved against the workspace.

The store is a bbolt database created on first use. Entries are keyed by Go import path and symbol, for example git.pr0.tech/rs/harness/internal/tools.ReadTool.Run. The store holds documentation only — references are computed from the live source tree on every read so they cannot go stale.

Documented read behaves exactly like read for non-Go files. For Go files it appends a Documentation section after the numbered contents, and, only when the call sets attach_references: true, a References section as well:

  • Documentation — the stored documentation for every declaration that overlaps the returned offset/limit range, or (no documentation).
  • References — under read_doc_reference_minimal: true (the default) the paths of the files that reference any of those declarations; with it false, the definition and usages of those declarations under the workspace (Go files only; vendor/, .git/, node_modules/, and testdata/ are skipped), capped at 200 lines and rendered as def/ref path:line: text.

attach_references is an optional read argument and defaults to false: references can be large, so they are opt-in per call to avoid overloading the model's context. When they are requested, read_doc_reference_minimal (default true) further trims the section to the referencing file paths alone, so only the files — not their contents or line numbers — reach the model.

The ast backend parses each file and labels declaration identifiers as the definition. Method references are qualified by receiver type, so a method Get on *Store no longer pulls in every Get in the tree: a match counts only when it is the method declaration whose receiver is that type (in the symbol's own package) or a selector x.Get whose base x resolves to a value of that type. Bases are resolved from receivers, parameters and results, var/:= declarations (composite literals such as &Store{} included) and struct fields declared with the type, so h.s.Get() is found when s *Store. Because no type-checking is performed, calls whose receiver type cannot be inferred — f().Get(), values held in interfaces, and promoted methods — are skipped. Non-method declarations keep the name-only match, and the grep backend remains a word-boundary text search that treats a match on the symbol's first line as the definition.

Documented write adds an optional documentation array of {symbol, doc} entries. Each entry is upserted into the store under the written file's package, so later documented reads surface it. For Go files, exported declarations with no supplied or stored documentation are listed as a warning in the tool output, or, with require_docs: true, cause the write to be refused before anything is changed. Non-Go files are written normally and the documentation entries are ignored.

Documented edit takes the same optional documentation array and applies the same rules, but validates the post-edit content: the replacement is computed in memory, the exported declarations of the resulting file are checked (so a new, undocumented declaration is reported or, with require_docs: true, refuses the edit before anything is written), and only then is the file written. Its replacement mechanics, preview, scope, and read-before-write guard are exactly the plain edit's. Non-Go files are edited normally and the documentation entries are ignored.

readdoc selects documentation straight from the store and never reads the source tree, so a symbol that was never documented reports as (no documentation) even if it carries a source doc comment. It takes two optional, mutually exclusive arguments:

  • symbol — an exact store key (example.com/mod/util.Add) or just the symbol name (Add, ReadTool.Run), matched against the key or any key ending in .<symbol>.
  • pattern — a regular expression matched against store keys.

With neither argument it lists every documented key, keys only and uncapped. Otherwise each matching key is printed followed by its documentation.

readast returns references only — no documentation section. It resolves a selection against the live workspace source (so it works for any declaration, not just documented ones) and prints the definition and usages for each match, one block per symbol, capped at 200 lines. It takes the same symbol/pattern arguments as readdoc (matched against the store key, the package-qualified name, and the bare leaf); exactly one is required.

writedoc and deletedoc edit the store directly, without touching any workspace file. Both take a key in the same form as readdoc: an exact store key, or a symbol name matching exactly one existing entry. An ambiguous key is an error. Because import paths contain dots a store key cannot be split back into its parts, so writedoc treats a key that matches nothing as a full store key to create.

  • writedoc upserts one entry, creating it if absent. Its doc argument may be empty, which stores an empty entry (distinct from no entry at all).
  • deletedoc removes the entry under key, not just its text. Deleting a key that is not documented is a no-op reported as success.

When expanded, a tool call shows extra output as labelled sections inside the same block: the documented write and edit show the documentation they stored, and writedoc/deletedoc show the key and the documentation as separate parts.

All four tools default to approval: never and start their output collapsed, and appear in the tool list only when documented_tools: true.

documented_tools: true
references: ast
require_docs: false
read_doc_reference_minimal: true

Exporting the store

--export-docs <filepath> dumps the documentation database to a YAML file and exits without starting the TUI, so the store can be inspected, committed, or moved between workspaces:

harness --export-docs docs.yaml --workdir /path/to/workspace

The database is located the same way the tools locate it (the docs_db setting, else <workdir>/.harness/docs.db), and the file is a list of key/doc entries ordered by key, with multi-line documentation written as a literal block:

docs:
  - key: example.com/mod/util.Add
    doc: |-
      Add adds two integers.
  - key: example.com/mod.Run
    doc: Run runs the module.

The database must already exist — a wrong path is an error rather than an empty file. A running harness holds the database lock, so when the store is locked it is copied first and the copy is read, letting you export from another terminal while the TUI is open.

Theming

Every color in the UI can be customized from config.yaml under a theme: key (see the commented block written into the default config). The theme has two levels:

  • palette — a small set of semantic tokens (accent, muted, dim, result, text, success, warning, danger, focus, user, onaccent, added, removed) that every element derives from. Set accent: "200" and every element that uses the accent color follows.
  • styles — per-element overrides keyed by slot name (header, user, tool_call, error, diff_added, block_border, pane_border_active, spinner, …). A slot value may name a palette token or a literal color.
  • markdown — colors for the assistant's rendered markdown, keyed by element (text, heading, emph, strong, code, link, link_text). Only the elements you set are recolored; the rest keep the base markdown style.

Only colors are configurable; weight and decorations (bold, italic) are fixed. A color is an ANSI 256 index (0–255) or a #rgb / #rrggbb hex value — named colors are not accepted because the renderer does not resolve them.

theme:
  palette:
    accent: "200"          # a bluer accent everywhere
    danger: "#ff5555"
  styles:
    diff_added: { bg: "22" }
    user: { fg: "#8fb3ff" }
  markdown:
    strong: "153"          # bold/emphasis highlights in light blue
    emph: "153"

The theme is optional and layered over the built-in dark palette, so a config with no theme: key looks exactly as before. An unknown palette token, an unknown style slot, or an unparseable color fails at startup rather than being silently ignored.

A theme can also live in its own file and be layered on top of the inline one with theme_file, resolved next to config.yaml with a .yaml extension assumed (theme_file: solarized loads solarized.yaml, which holds the same theme: structure):

# config.yaml
theme_file: solarized

Custom commands

The optional commands list turns your own shell commands into tools. Each entry is presented to the model exactly like a built-in tool: it appears in the tool list (native function calling or the XML fallback) and its call is handled like any other tool call, including the approval prompt and the collapsible output block.

Field Description
name Tool name. Letters, digits, _, and - only; must not collide with a built-in tool.
description What the command does. Sent to the model.
command Shell command to run, passed to bash -c verbatim.
approval always, never, or workdir. Defaults to always.
collapsed true starts the output block collapsed.
disabled true hides the command from the model and rejects calls to it.
parameters Optional list of arguments the model may pass.

Parameters are forwarded to the command as environment variables prefixed with HARNESS_ARG_, for example a parameter named ref becomes $HARNESS_ARG_ref. This avoids shell-quoting and injection problems. A parameter has a name, an optional description, a type, required: true to make it mandatory, and an optional default used when the model omits it.

The parameter type is one of:

Type Description
string Default. Any text, passed through unchanged.
integer A whole number. Exposed to the model as a JSON integer and validated on the way in.
path A filesystem path. Checked against the workspace for workdir approval (see below).
commands:
  - name: run_tests
    description: Run the test suite, optionally filtered by package.
    command: go test "$HARNESS_ARG_package"
    parameters:
      - name: package
        description: Import path to test. Defaults to the whole module.
        type: string
        default: ./...
      - name: timeout
        description: Per-test timeout in seconds.
        type: integer
        default: "30"

A command that counts the lines of a file:

commands:
  - name: count_lines
    description: Count the number of lines in a file.
    command: wc -l < "$HARNESS_ARG_file"
    approval: workdir
    collapsed: true
    parameters:
      - name: file
        description: Path to the file to count, relative to the workspace.
        type: path
        required: true

Here the model calls the tool with a file argument, which Harness forwards as $HARNESS_ARG_file. The command runs wc -l on it with the workspace as the working directory and returns the line count. Because file is a path parameter and approval is workdir, the call is approved automatically when the path stays inside the workspace and prompts otherwise. collapsed: true starts its output block collapsed.

Output options

Every custom command (and the bash tool) exposes optional call arguments that tune the captured output. Three are declared per call; the rest happen automatically.

Declared per call:

  • head: N keeps only the first N lines.
  • tail: N keeps only the last N lines. Combined they select a window, e.g. head: 100, tail: 10 keeps the last 10 of the first 100 lines.
  • filter: "<regex>" keeps only the lines matching the pattern. It is applied before head/tail.

Applied automatically, with no argument:

  • ANSI escape sequences are stripped, so colorized output (git, ls, test runners) reads cleanly.
  • Output beyond the limits (2000 lines / 51200 bytes) is elided in the middle — the head and tail are kept and the full text is written to a temp file whose path is shown in the elision marker, so the model can read it back.

Two further options make the captured output more informative, mirroring the 2>&1; echo "---EXIT: $?---" shell idiom:

  • exit_status: true appends the command's exit status to its output as a ---EXIT: <status>--- line. A non-zero status is still reported as an error.
  • redirect_stderr: true merges stderr into stdout (the equivalent of 2>&1), preserving the shell's interleaved order. When it is off, stderr is captured separately and appended under a --- stderr --- header so it is never lost.

These are tool-call options the model chooses per call, not configuration options: they are not set in the config file and all default to off. A malformed value (for example a negative head or an invalid filter regexp) is reported as an error rather than silently ignored. Because the names are reserved, they cannot be used for a user-defined parameter.

Notes:

  • The command runs in a fresh bash -c process on every call, with the workspace as its working directory, a 120s timeout, and the output options above. Shell state does not persist between calls.
  • ${VAR} expansion is applied to provider, model, workdir, and command metadata fields, but never inside command, so shell syntax such as $HOME, $1, and $$ reaches the shell untouched.
  • Parameters reach the command as HARNESS_ARG_<name> environment variables, so refer to them with ordinary shell expansion ("$HARNESS_ARG_file"). A quoted heredoc (<<'EOF') disables that expansion, so a $HARNESS_ARG_x written inside one arrives literally. Read it from the environment instead (for example os.environ["HARNESS_ARG_x"] in Python) or pass it in as an argument before the heredoc (python3 - "$HARNESS_ARG_x" <<'EOF').
  • In workdir mode a custom command is auto-approved only when it declares at least one path parameter and every path parameter resolves inside the workspace. A command with no path parameters can reach anywhere, so it still prompts.
  • Duplicate names and names that shadow a built-in tool are rejected at load time. Parameter names and types are validated too, and the reserved output option names (exit_status, redirect_stderr, head, tail, filter) are rejected.

Command files

Custom commands can be kept out of the main config to avoid cluttering it (and the tool list) until they are actually needed. A command file is a YAML file with the same top-level commands structure as config.yaml:

# ~/.config/harness/golang.yaml
commands:
  - name: go_test
    description: Run the Go tests, optionally for one package.
    command: go test "$HARNESS_ARG_package"
    approval: never
    parameters:
      - name: package
        description: Import path to test.
        default: ./...

Load such a file either from the startup flag or from the main config:

  • --commands golang loads ~/.config/harness/golang.yaml. The flag is repeatable and accepts a comma-separated list, e.g. --commands golang,rust.
  • command_files: [golang] in config.yaml loads the same file on every start.

A bare name is resolved next to the config file, and a .yaml extension is assumed unless one is given; an absolute path may be used to load a file from anywhere. Names listed both in command_files and on the command line are only loaded once. The commands from every source are validated together, so a name that collides with a built-in tool, an inline command, or another file is rejected at load time.

Six ready-to-use files ship in examples/commands/: copy them into ~/.config/harness/ and load them with command_files or --commands:

  • basics.yaml — language-agnostic development commands (find, list, search, count/head/tail lines, timestamp, and a set of git helpers).
  • golang.yaml — Go-specific commands (go build/test/vet/mod tidy, gofmt, coverage, running a package).
  • python.yaml — Python-specific commands (running scripts and modules, pytest, coverage, ruff, mypy, virtual environments, and pip). Everything runs through the project virtual environment (the venv argument, default .venv), created with python_venv; pip also runs with --require-virtualenv, so installs abort instead of touching the system or user site-packages.
  • reveng.yaml — software reverse engineering commands (file, strings, xxd, binwalk, binutils disassembly, checksec, capa, YARA, rizin, gdb, and strace). Read-only inspection is auto-approved; extraction, unpacking, and execution/debugging are gated.
  • pdf.yaml — PDF extraction commands (metadata, layout-preserving and per-page text, text search, fonts, embedded images and attachments, qpdf structure checks, PNG rendering, splitting/merging/compressing, and OCR with tesseract). Reading and printing text is auto-approved; commands that write images, attachments, or derived PDFs are workdir-scoped.
  • latex.yaml — LaTeX document and Beamer presentation commands (latexmk builds with pdfLaTeX, XeLaTeX, or LuaLaTeX, builds under an alternate job name, page-to-PNG previews, log and error inspection, texcount, in-place latexindent, and auxiliary-file cleanup). Builds never enable -shell-escape, so a document cannot run shell commands; log and word-count commands are auto-approved and everything that writes is workdir-scoped.

Providers

Each provider is an OpenAI-compatible endpoint. The built-in names are llama, zai, mistral, and deepseek, but you can add any other OpenAI-compatible endpoint with the same fields.

Field Description
base_url Base URL, e.g. http://localhost:8080/v1.
api_key API key. Supports ${ENV_VAR}.
model Model name sent in requests.
tool_calls native, xml, or both (see below).
context_length Context window in tokens. 0 means auto-detect (llama.cpp) or unknown.

Tool-call modes

  • native — use the API's built-in function calling (tools / tool_calls).
  • xml — inject tool signatures into the prompt and parse <tool_call> blocks from the model output. Useful for local models without function-calling support.
  • both — send native tools and also accept the XML fallback.

Context usage

The header shows the current context usage, e.g. ctx 12.3k/64k (19%), color-coded green below 60%, yellow to 85%, and red above. Usage comes from the API's reported token counts when available (requested via stream_options), falling back to a local estimate. context_length provides the denominator; for local providers left at 0, Harness queries llama.cpp's /props endpoint to detect it automatically.

Context compaction

When the conversation grows too large, Harness can summarize it and replace the history with the summary, freeing the context window while keeping the thread of the work. The model is asked for a concise summary of the goals, decisions, files touched, commands run, results, and outstanding tasks; that summary is then sent as a single message in place of everything before it.

  • Trigger it yourself at any time with /compact. It only works while the agent is idle; while a turn is running the command reports that it cannot compact.
  • Let it happen automatically by setting autocompact_limit in the config to a token count. Before each model call, if the context usage has reached that limit the conversation is compacted first. 0 (the default) disables automatic compaction.

Either way a notice is added to the transcript showing the token usage before and after, e.g. context compacted: 48k → 3k tokens. Compaction makes one extra model call to produce the summary; press Esc to cancel it.

Small local models

A small local model (a few billion parameters, an 8k–32k window) can drive Harness, but the fixed prompt footprint matters more than with a hosted model: the system prompt, every registered tool's schema, and each tool result all compete for the same window. Two settings keep it from overflowing.

provider: llama
providers:
  llama:
    base_url: http://localhost:8080/v1
    model: gemma-4-e4b
    # llama.cpp without native function calling: tools live in the prompt
    tool_calls: xml
    # leave 0 to auto-detect from llama.cpp's /props; set it explicitly when
    # the server does not report its window
    context_length: 8192

# Derive the compaction threshold from the window (60% of it, or a conservative
# 4096 when the window is unknown) and cap each tool result at 2000 tokens.
small_context: true

# Optional explicit overrides; these win over the small_context defaults.
# autocompact_limit: 5000
# max_tool_result_tokens: 1500

# Small models loop more easily; a lower cap ends a runaway turn sooner.
max_iterations: 40

What the profile changes:

  • small_context derives autocompact_limit from the active provider's context_length, so compaction happens before the window fills. Explicit autocompact_limit and max_tool_result_tokens values still win.
  • Each tool result is capped at max_tool_result_tokens (2000 with the profile). A longer result keeps its beginning and end plus a marker telling the model to narrow the call (read offset/limit, head/tail/filter).
  • Independently of the setting, when the provider reports a context window, usage at 90% of it forces a compaction before the next model call, so one large command cannot overflow the window.

Other levers, all ordinary config:

  • Disable tools the task does not need (tools: { webfetch: { disabled: true } }) and load only the command files you actually use. Tool schemas cost prompt tokens on every call: the ten built-in tools are about 1.5k tokens, while adding golang.yaml grows that to about 3.8k, and python.yaml or reveng.yaml to about 5.7k — significant shares of an 8k window.
  • Keep ~/.config/harness/systemprompt.md short.
  • Prefer xml tool calls for llama.cpp servers without native function calling, and native for servers that support it (LM Studio, Ollama, vLLM).

System prompt

Edit ~/.config/harness/systemprompt.md to change the global system prompt that is prepended to every conversation. Tool definitions are appended automatically.

Tools

Tool Description
bash Run shell commands in a persistent session, with timeout and output options (head/tail/filter).
read Read a file with line numbers (offset/limit supported).
write Create or overwrite a file (must be read first if it exists).
edit Exact string replacement in a file; fails on missing/ambiguous matches.
delete Remove a file, or a directory with recursive: true. Never removes the workspace root.
move Move or rename a file or directory, creating parent directories; refuses to overwrite unless overwrite: true.
glob Find files by glob pattern (supports **).
grep Search file contents with a regular expression, optionally filtered.
webfetch Fetch a URL and convert HTML to markdown/text.
todo Replace the session task list (content + status), repeated in the system prompt so the plan survives compaction.

delete and move are scoped like write and edit: with approval: workdir they are auto-approved when every path they touch resolves inside the workspace and still prompt otherwise. They prompt by default, like the other mutating tools; opt in with:

tools:
  delete: { approval: workdir }
  move:   { approval: workdir }

todo only changes in-memory session state, so it never prompts. Its list is appended to the system prompt on every request while non-empty, which keeps the plan visible after a compaction replaces the conversation history.

When the workspace was cloned from a Forgejo repository (or forgejo_url / forgejo_repo are set), the Forgejo tools are added. The read-only tools run without a prompt:

Tool Description
forgejo_list_issues List issues (state/label/search filters).
forgejo_get_issue Fetch one issue with its comments.
forgejo_list_action_runs List Forgejo Actions workflow runs.
forgejo_get_action_run Fetch a run with its jobs, optionally with each job's log.

The writing tools prompt before every call (see Forgejo):

Tool Description
forgejo_create_issue Open a new issue.
forgejo_comment_issue Add a comment to an issue.
forgejo_edit_issue Change an issue's title, body, assignees, or labels.
forgejo_close_issue Close an issue.

When documented_tools: true, read, write, and edit are the documented variants that also surface stored documentation and (for read, only when attach_references: true is passed) Go references, and four extra tools are added: readdoc (select documentation from the store), writedoc (upsert one entry), deletedoc (remove one entry), and readast (return references only). See Documented tools.

With inter-agent messaging enabled (a group plus the controller URL), two extra tools are available: list_agents and message_agent (see Agent groups).

Forgejo

When the workspace is a Forgejo repository, Harness registers a set of tools to read and update its issues and to inspect its Actions runs. This makes the agent able to triage a bug report, comment with findings, or check why CI failed, without leaving the session.

Target resolution. The instance URL and owner/repo are derived from the workspace's origin git remote (https, ssh, and scp-like remotes are all understood). Set forgejo_url and forgejo_repo to target a different repository, or when the workspace has no git remote. The tools are not registered when no repository can be resolved.

Authentication. The token is read from forgejo_token, then HARNESS_FORGEJO_TOKEN, then HARNESS_GIT_TOKEN, so a git clone token is reused by default. It is sent as Authorization: token <token> and is never logged. An empty token works for a public repository.

Approval. Reading is split from writing so reads are auto-approved while every write is gated. forgejo_list_issues, forgejo_get_issue, forgejo_list_action_runs and forgejo_get_action_run default to approval: never; forgejo_create_issue, forgejo_comment_issue, forgejo_edit_issue and forgejo_close_issue default to approval: always. They use the same per-tool tools: policy as every other tool, including the disabled and collapsed options.

# Optional: only needed when the workspace has no git remote, or to target a
# different repository than the one cloned.
# forgejo_url: https://git.example.com
# forgejo_repo: owner/repo
# forgejo_token: ${HARNESS_FORGEJO_TOKEN}
tools:
  forgejo_close_issue: { approval: never } # e.g. let the agent close issues
  forgejo_list_action_runs: { collapsed: true }

The action log shown by forgejo_get_action_run is capped to the last 200 lines per job by default (log_lines overrides it), keeping a long CI log from flooding the model.

Project layout

main.go                 Entry point, flags, bootstrap, controller/agentd/spawn subcommands
cmd/harness-controller  Command-center backend binary (fleet API + relay + pods)
internal/config         YAML config, defaults, env expansion
internal/provider       OpenAI-compatible client, XML tool-call parser, detection
internal/agent          Model/tool loop, history, context accounting
internal/session        Session interface, in-process adapter, remote client
internal/wire           Console <-> agent message protocol
internal/bus            Inter-agent message types and controller client
internal/metrics        Prometheus instrumentation (agent + controller)
internal/audit          JSON-lines audit log (approvals, lifecycle, auth)
internal/agentd         Agent-as-a-service WebSocket server (workspace endpoints)
internal/controller     Session registry, fleet API, console relay, spawn/fetch
internal/kube           Cluster backend: per-session Deployment/Service/Secret
internal/workspace      git clone, tar pack/unpack for workspace sources
internal/tools          Tool implementations and registry
internal/forgejo        Forgejo REST client (issues and Actions)
internal/docs           Documentation store, Go symbol index, reference lookup
internal/diff           Line diffing for display
internal/ui             Bubble Tea TUI (console + fleet view)
charts/harness-agent    Helm chart for a single session (reference)
deploy/                 kustomize + ArgoCD manifests and observability extras
Dockerfile.agent        session/agent image (harness + shell, git, ripgrep, ansible)
Dockerfile.controller   controller image (static binary, distroless)
test/e2e                build-tagged k3d end-to-end test
scripts/                developer scripts (agent-entrypoint.sh, e2e-k3d.sh)
.forgejo/workflows      CI pipeline

Development

go test ./...
go vet ./...

End-to-end test

test/e2e/ holds a build-tagged test (tag e2e) that runs the whole platform on a real Kubernetes API and checks the full loop: the controller creates a session as a Deployment, the pod becomes Ready and serves the agent, and deleting the session removes the pod. It runs two ways:

# Against a throwaway k3s cluster created with k3d (needs docker + k3d):
K3D_E2E=1 go test -tags e2e -timeout 20m ./test/e2e/...   # or: scripts/e2e-k3d.sh

# Against a cluster you already deployed to (needs kubectl):
E2E_CONTEXT=<kubectl-context> E2E_TOKEN=<controller-token> \
  go test -tags e2e -timeout 20m -v ./test/e2e/...

Because it needs cluster tooling, it is excluded from go test ./... and is not run by CI: the k3d mode needs Docker, which the default Forgejo runner does not provide. Run either mode by hand — scripts/e2e-k3d.sh on a Docker-capable machine, or E2E_CONTEXT against a deployed cluster. K3D_E2E_KEEP=1 leaves the k3d cluster up for debugging, and E2E_RUN_TURN=1 (with E2E_PROVIDER) also drives one real prompt. See docs/e2e.md for the full runbook, including deploying to your own cluster, image pulls, ArgoCD/Prometheus verification and troubleshooting.

Releases

The Forgejo Actions pipeline (.forgejo/workflows/ci.yml) runs on pushes to main, pull requests, and v*.*.* tags:

  • test — gofmt, go vet, and go test.
  • build — a static linux/amd64 binary with the version embedded, plus a checksums.txt, uploaded as a workflow artifact on every run.
  • release — on a strict semver tag (vX.Y.Z) only, it creates a Forgejo release and attaches the binary and checksums.

CI and Images serialize runs for the same commit (a concurrency group keyed by github.sha, cancel-in-progress: false), so pushing a commit to main and then tagging that same commit queues the tag run behind the branch run instead of executing both at once. Neither run is cancelled, so both the branch's main/sha- images and the tag's version images still get built. Each workflow uses its own group prefix because concurrency groups span every workflow in the repository.

To cut a release, tag and push:

git tag v1.0.0
git push origin v1.0.0

The version is injected at build time and can be checked with harness --version.

Container images

.forgejo/workflows/images.yml builds the two images (Dockerfile.controller, Dockerfile.agent) and pushes them to the Forgejo container registry on pushes to main, on v*.*.* tags, and on manual dispatch:

  • <registry>/<owner>/harness-controller:<tag>
  • <registry>/<owner>/harness-agent:<tag>

The registry host and owner are derived from the Forgejo server URL and the repository, so the workflow needs no editing per instance. Tags are the git tag (v1.2.3, plus latest) or the branch name (main), and every build also gets a sha-<short> tag for traceability. Pushing uses a personal access token supplied through the repository secrets REGISTRY_TOKEN and REGISTRY_USER (see below): Forgejo ignores the GitHub permissions: field, so the automatic GITHUB_TOKEN cannot be granted package access.

Builds run buildah (daemonless), so no Docker socket is needed. The job runs inside quay.io/buildah/stable, which already ships buildah, git and bash — so there is no runtime package install, and the checkout is a plain git fetch (the image has no node for actions/checkout). It sets STORAGE_DRIVER=vfs and BUILDAH_ISOLATION=chroot so it works inside the runner's container; if your runner image already supports overlayfs, set STORAGE_DRIVER back to overlay. Pin the image by digest and/or point it at a local buildah image if the runner cannot reach quay.io.

Pushing needs a credential with package-write permission. Forgejo does not support the GitHub permissions: field (it warns and ignores it), so the automatic GITHUB_TOKEN cannot be granted package access — you must supply a personal access token that has both the write:package scope and repository access covering the owner namespace.

  1. Settings → Applications → Generate new token.
    • Scope: write:package (the "package" Read and Write entry; write implies read).
    • Repository/org access: "All (public, private, and limited)". Do not pick "Specific repositories" — that access mode forbids the package scope. "Public only" also limits package access to public owners.
  2. Then add repository secrets under Settings → Actions → Secrets:
Secret Value
REGISTRY_USER your Forgejo username (the login, not your email)
REGISTRY_TOKEN the access token from step 1

The names are case-sensitive and must match exactly. The token's user must own (or be able to write to) the <owner> namespace it pushes to, and the token must be allowed to access that repository. A token with the package scope but no repository access will buildah login successfully yet be rejected on the first push with authentication required on a blob HEAD — if you see that, add repository access to the token. The workflow logs which credential stage failed (empty secret, login failure, or push denial) and trims whitespace/newlines from a pasted secret, since that is a common cause of spurious authentication required.

Authorized Integrations (future option)

A secretless alternative is to push with a short-lived Authorized Integration JWT instead of a long-lived PAT. The container registry accepts the JWT as a basic-auth password (AuthorizedIntegration with PermitBasic), so buildah login --username "$REGISTRY_USER" --password-stdin works with it. It is not wired up here because it adds an OIDC/JWT-fetch step (and the buildah image has no jq, so the token has to be extracted with sed), but it is a drop-in replacement for REGISTRY_TOKEN when the PAT becomes a maintenance burden: create a "Forgejo Actions (Local)" integration (source repo, write:package, owner access), enable enable-openid-connect: true, fetch the JWT from $ACTIONS_ID_TOKEN_REQUEST_URL/$ACTIONS_ID_TOKEN_REQUEST_TOKEN, and feed it to buildah login.

Point the deploy overlay at the pushed images (see Running on Kubernetes and docs/e2e.md).