Peen puts a coding agent in a real working directory and keeps the whole job alive after the first response. Connect a client over WebSocket, give it a task, and it can read code, edit files, run commands, use skills, launch child agents, and follow the rules sitting beside the project.
Every conversation, tool call, agent event, context snapshot, compaction, and provider exchange lands in SQLite. Model records keep the request and response, thinking, usage, retries, cost, model identity, and connection name, never the provider credential. Reconnect after a restart and the history is still there. Every connected client receives the live feed for every session, then renders the conversations it wants from each event's session ID.
One host runs one control plane. It owns SQLite, the API, and the event feed, and it starts a separate worker process per session to run that session's turns. Which environment a worker gets, a child process or its own container, is an operator decision. See Architecture.
Peen is the backend and harness. It does not ship a browser chat UI. Bring a browser client, terminal client, bot, or your own application.
curl -fsSL https://raw.githubusercontent.com/psyb0t/peen/main/install.sh | bash
That clones Peen into a temporary directory, builds it, installs the binary to
~/bin, and deletes the clone. Set PREFIX for somewhere else and REF for a
tag or branch:
curl -fsSL https://raw.githubusercontent.com/psyb0t/peen/main/install.sh |
PREFIX=/usr/local/bin REF=v0.11.0 bash
Piping a script from the internet into a shell is worth a look first. Read it at install.sh, or do the same thing by hand:
git clone https://github.com/psyb0t/peen.git
cd peen
make install
Either way you need Docker. The build runs in a pinned Go image, so no local Go
toolchain is involved. make install puts the binary in ~/bin unless you pass
PREFIX. Deployment covers the other routes, including
go install and building the image yourself.
Run Peen in Docker first. Pick a workspace you are happy to hand to an agent. Do not mount your whole home directory just because it is convenient.
You need Docker and a provider API key. Copy the example configuration:
cp .env.example .env
The example has AIGate and Z.ai entries. Keep the provider you use, set its model ID, and put its key in the named environment variable. For an OpenAI-compatible AIGate setup, the important lines look like this:
PEEN_CONFIG_DIR=/absolute/path/to/peen/config
PEEN_STATE_DIR=/absolute/path/to/peen/state
PEEN_UPSTREAMS=[{"name":"aigate","type":"openai","baseUrl":"https://aigate.example/v1","apiKeyEnv":"AIGATE_TOKEN"}]
PEEN_DEFAULT_MODEL=aigate/your-model-id
PEEN_COMPACTION_MODEL=aigate/your-model-id
AIGATE_TOKEN=your-token-here
PEEN_API_TOKEN=
PEEN_CONFIG_DIR and PEEN_STATE_DIR are separate on purpose. Workers get the
first one read-only and never get the second. Peen refuses to start if one sits
inside the other.
.env is a Docker --env-file, so leave the JSON unquoted. For a server
outside your own machine, set PEEN_API_TOKEN to a real secret before starting
it.
Build the image, create separate configuration, state, and workspace directories, then mount each at its literal host path. Literal paths matter when the controller starts Docker workers. Docker resolves worker mounts on the host, not inside the controller container.
make docker-build
root="$PWD"
config="$root/data/peen/config"
state="$root/data/peen/state"
workspace="$root/workspace"
mkdir -p "$config" "$state" "$workspace"
docker run --rm \
--user "$(id -u):$(id -g)" \
--env-file .env \
-e PEEN_CONFIG_DIR="$config" \
-e PEEN_STATE_DIR="$state" \
-e PEEN_HOST_USERNAME="$(id -un)" \
-e PEEN_HOST_HOME="$HOME" \
-p 8080:8080 \
-v "$config:$config" \
-v "$state:$state" \
-v "$workspace:$workspace" \
-w "$workspace" \
peen run
The control process and workers run as your UID and GID, so files the agent
creates stay yours. PEEN_HOST_USERNAME and PEEN_HOST_HOME let a Docker
worker recreate that account inside its own image. config holds the trusted
harness layer and workers receive it read-only. state holds the database,
audit logs, and worker sockets and workers never receive it. workspace is the
process working directory and agent workspace. Peen starts with no sessions and
opens one when a client names that directory. It resumes the same session when
it restarts with the same state directory and workspace. All three mounts
survive a container restart.
Peen starts with no sessions, so open the workspace first, then route a message
to the session it returns. With the default empty PEEN_API_TOKEN, open a
browser console and paste this:
const workspace = "/absolute/path/to/workspace";
const opened = await fetch("http://localhost:8080/v1/sessions/open", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspace }),
}).then((response) => response.json());
const sessionId = opened.session.id;
const socket = new WebSocket("ws://localhost:8080/v1/ws");
socket.addEventListener("message", ({ data }) => console.log(JSON.parse(data)));
socket.addEventListener("open", () => {
socket.send(JSON.stringify({
id: crypto.randomUUID(),
type: "message.send",
data: { message: "Read the project, then tell me what you would fix first." },
metadata: { sessionId },
timestamp: Math.floor(Date.now() / 1000),
triggeredBy: null,
}));
});
Opening the same directory again returns the same session, so this is also how
you reattach after a restart. Save the sessionId for REST reads and controls.
Native agent events arrive while it works, then message.completed says that
submission is done. The socket stays open for the next task, which names the
same session.
Every connected client receives every session's live events. A client renders
tabs by filtering received events on metadata.sessionId. Add
?sessionId=<uuid> to its WebSocket URL only when it deliberately wants the
server to send one session's events. The full protocol, including browser
authentication, failed turns, queued messages, and event fields, lives in the
WebSocket API guide.
Give every provider a short local name. Models are then addressed as
provider/model, for example aigate/your-model-id or zai/glm-5.3. Peen
asks each configured provider which models it actually offers at startup. A
misspelled or unavailable model fails early instead of burning a turn.
Each upstream also declares a type, which is the wire protocol it speaks
rather than the vendor behind it. An OpenAI-compatible gateway is
type: "openai" whoever runs it. The supported types are openai,
anthropic, and zai-coding. zai-coding keeps Z.ai thinking state through
tool rounds. A message.send can override
the model for that one task. Peen never guesses task difficulty or silently
switches models behind your back.
The full list of provider, context, tool, and event settings is in Configuration.
PEEN_CONFIG_DIR holds an optional trusted base harness. PEEN_STATE_DIR
holds durable controller state and is never mounted into a worker. The workspace
adds project-specific instructions:
workspace/
AGENTS.md
.claude/
rules/<rule-name>.md
skills/<skill-name>/SKILL.md
.agents/
rules/<rule-name>.md
skills/<skill-name>/SKILL.md
agents/<agent-name>.md
events/<event-type>.md
hooks.yaml
Write ordinary project rules in AGENTS.md. Split topic-specific always-on
rules into Markdown files in .claude/rules/ or .agents/rules/. Add a skill
when the agent needs a named procedure. Peen puts every skill name and
description in the turn context. For an ordinary request, the model decides
whether the task matches a skill, then calls use_skill to load it. Put a
standalone :skill-name at the start of a message or after whitespace to
require that exact effective skill for the turn. Peen validates it before
contacting a provider and injects its full SKILL.md into the root and
child-agent prompts. Add a named agent when it should delegate a bounded job.
Use hooks when the harness itself must gate, annotate, or react to an action.
Peen resolves layers from the filesystem root down to the active workspace, so a repository can put broad rules at the top and narrow rules beside one component. The configuration directory can add a trusted base layer.
Full layering, event, and hook details: Configuration and Hooks.
The WebSocket is for live work. REST is for durable reads and control. It never starts a turn.
curl "http://localhost:8080/v1/messages?limit=50&order=asc" \
-H "X-Session-ID: <session-id>"
curl -X POST "http://localhost:8080/v1/session/cancel" \
-H "X-Session-ID: <session-id>"
REST also lists session state, durable protocol events, outside notices,
process output, child-agent runs, compaction history, and every model request
and response. REST addresses a known session through X-Session-ID, so a
client keeps the UUIDs for the conversations it owns. The API
reference has every request and response.
The same binary is also a local control client. Each command talks to a running
controller over the control API. None of them opens the database or starts a
second supervisor: when nothing is listening they start peen run and wait for
it to answer.
peen control status
peen session open /srv/work/project
peen session list
peen session attach <session-id>
peen session stop <session-id>
peen control status reports reachability without starting anything, so it
tells "not running" apart from "running". peen session open prints the
session ID, its workspace, and whether the call created the session or resumed
one. peen session attach streams that session's live events and starts no
turn. peen session stop cancels the session's active turn and says when there
was nothing running. The commands read the same PEEN_ configuration the
controller does, so a command and its controller cannot disagree about the
endpoint.
A native deployment currently reaches the controller over the configured loopback HTTP endpoint. Unix-domain-socket discovery is not implemented: the vendored HTTP server creates TCP listeners only and exposes no way to supply one, so it needs an upstream capability first.
Set PEEN_API_TOKEN and use wss:// outside local development. Browser
WebSockets authenticate with subprotocols because browsers cannot attach an
Authorization header. The API reference has the
exact handshake.
Peen writes structured logs to stdout and keeps daily audit files under
PEEN_STATE_DIR/logs by default. The active workspace is a default, not a
containment boundary. An absolute tool path can still point outside it. Long
conversations either drop old request context or replace it with a stored
summary. Configuration covers all of this.
Read this before you deploy Peen anywhere it can reach something you do not want touched.
A session's tools run in a worker process under the execution profile the
operator picked for that session. On the default native profile that worker is
a child of the controller, so run_command and the file tools have exactly the
access of the operating-system user running Peen. There is no path allowlist, no
secret-file denylist, and no approval or permission step before a tool runs. A
file tool can read, write, or remove any path that user can touch, including
.git/, .env, and SSH keys. run_command executes an arbitrary shell command
immediately. That is deliberate: the product is a coding agent with real access.
A docker profile is the isolation boundary. It runs the worker in its own
container with only the mounts, network, and capabilities the operator defined.
A client picks a profile by name and never sends an image, mount, network
setting, or capability, so the blast radius is an operator decision. If the
controller cannot reach a Docker socket, a session on a Docker profile is
refused rather than run on the host. See
Configuration.
A worker container receives the workspace, PEEN_CONFIG_DIR read-only, its own
session socket directory, and only the runtime configuration it needs, including
the named provider credential for its model calls. It does not receive
PEEN_STATE_DIR, the control API token, or the controller Docker socket. The
provider credential is therefore available to the agent process. Treat it like
any other secret exposed inside an agent workspace. Peen runs the image published
alongside the running build unless PEEN_WORKER_IMAGE or the profile names
another. The container starts as root and the Peen entrypoint drops it back to
your host account, so an image without that entrypoint fails when the worker
runs.
Tool calls and their results are recorded verbatim in the session transcript and sent live over the global WebSocket feed, exactly like any other message. If the agent reads a file containing a secret, or a command prints one to stdout, that secret now exists in the SQLite transcript and in every connected client unless it requested a server-side session filter. Peen does not scan for or redact secret-shaped content in tool output. Treat the transcript and the event stream at the same sensitivity level as the files and commands the agent can reach.
remove_path has exactly one built-in restriction, and it is a guard against
a catastrophic typo, not a permission system: it refuses to remove the
filesystem root or the session's own workspace directory. Every
other path, including everything named above, is removable.
Run Peen as a non-root user, in a container, with only the mounts, network access, and capabilities the deployment actually needs. The production image does exactly this by default; see below.
The local Docker command above is the normal way to run Peen. The image has a real shell and the tools a coding agent uses. Its image default is a non-root account, and the documented command deliberately overrides that with your UID and GID so controller and worker changes keep host ownership. For source builds, production mounts, networking, and container hardening, read Deployment.
The peen agent skill teaches an agent how to configure and run Peen, send
work over WebSocket, add workspace harness layers, and inspect durable state.
It is documentation only. Installing it does not start a server, run a hook,
or change a workspace.
After the next Peen release and its matching psyb0t/agents marketplace entry:
claude plugin marketplace add psyb0t/agents
claude plugin install peen@psyb0t
codex plugin marketplace add psyb0t/agents
codex plugin add peen@psyb0t
openclaw skills install @psyb0t/peen
| You want to | Read |
|---|---|
| Start from zero | Getting started |
| Configure providers, limits, logs, and harness layers | Configuration |
| Build a WebSocket or REST client | API reference |
| Add hard checks or model instructions around actions | Hook configuration |
| Run it outside a local Docker command | Deployment |
Content type
Image
Digest
sha256:4ae5ce7c5…
Size
94.2 MB
Last updated
15 days ago
docker pull psyb0t/peen