- Go 99.8%
- Shell 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| .harness | ||
| charts/harness-agent | ||
| cmd/harness-controller | ||
| deploy | ||
| docs | ||
| examples/commands | ||
| internal | ||
| scripts | ||
| test/e2e | ||
| .gitignore | ||
| Dockerfile.agent | ||
| Dockerfile.controller | ||
| go.mod | ||
| go.sum | ||
| main.go | ||
| main_test.go | ||
| README.md | ||
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
agentdstartup with a shallow fetch of the requested ref (branch, tag, or commit) and checks it out;--pathlifts 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 inHARNESS_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
--excludeand any.harnessignore) and streams it to the session. Uploads are capped at 64 MiB. - Both
harness agentdand the controller accept workspace flags; the controller builds theagentdcommand for a spawned session from itsCreateSpec.
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; therealoverlay narrows that rule to your service and node CIDRs with anipBlock. Tighten the otheregressblocks 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.
agentdand 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;offdisables; alsoHARNESS_AUDIT_LOG). - Approvals. A remote session gates mutations on the console by default;
autonomous sessions should use
never/workdironly 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 whentois 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
*.yamlcommand 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/limitrange, or(no documentation). - References — under
read_doc_reference_minimal: true(the default) the paths of the files that reference any of those declarations; with itfalse, the definition and usages of those declarations under the workspace (Go files only;vendor/,.git/,node_modules/, andtestdata/are skipped), capped at 200 lines and rendered asdef/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.
writedocupserts one entry, creating it if absent. Itsdocargument may be empty, which stores an empty entry (distinct from no entry at all).deletedocremoves the entry underkey, 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. Setaccent: "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: Nkeeps only the firstNlines.tail: Nkeeps only the lastNlines. Combined they select a window, e.g.head: 100, tail: 10keeps the last 10 of the first 100 lines.filter: "<regex>"keeps only the lines matching the pattern. It is applied beforehead/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: trueappends 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: truemerges stderr into stdout (the equivalent of2>&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 -cprocess 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 insidecommand, 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_xwritten inside one arrives literally. Read it from the environment instead (for exampleos.environ["HARNESS_ARG_x"]in Python) or pass it in as an argument before the heredoc (python3 - "$HARNESS_ARG_x" <<'EOF'). - In
workdirmode a custom command is auto-approved only when it declares at least onepathparameter and everypathparameter resolves inside the workspace. A command with nopathparameters 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 golangloads~/.config/harness/golang.yaml. The flag is repeatable and accepts a comma-separated list, e.g.--commands golang,rust.command_files: [golang]inconfig.yamlloads 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 ofgithelpers).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, andpip). Everything runs through the project virtual environment (thevenvargument, default.venv), created withpython_venv;pipalso 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, andstrace). 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,qpdfstructure checks, PNG rendering, splitting/merging/compressing, and OCR withtesseract). Reading and printing text is auto-approved; commands that write images, attachments, or derived PDFs areworkdir-scoped.latex.yaml— LaTeX document and Beamer presentation commands (latexmkbuilds with pdfLaTeX, XeLaTeX, or LuaLaTeX, builds under an alternate job name, page-to-PNG previews, log and error inspection,texcount, in-placelatexindent, 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 isworkdir-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_limitin 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_contextderivesautocompact_limitfrom the active provider'scontext_length, so compaction happens before the window fills. Explicitautocompact_limitandmax_tool_result_tokensvalues 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 (readoffset/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 addinggolang.yamlgrows that to about 3.8k, andpython.yamlorreveng.yamlto about 5.7k — significant shares of an 8k window. - Keep
~/.config/harness/systemprompt.mdshort. - Prefer
xmltool calls for llama.cpp servers without native function calling, andnativefor 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, andgo test. - build — a static
linux/amd64binary with the version embedded, plus achecksums.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.
- 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.
- Scope:
- 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).