Sign inSign up

psyb0t/peen

By psyb0t

•Updated 15 days ago

Image
0

488

psyb0t/peen repository overview

Source⁠

⁠peen

CI coverage version license Docker Pulls

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.

⁠Contents

⁠Install

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 it

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.

⁠Send it a task

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⁠.

⁠Provider configuration

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⁠.

⁠Make it understand your project

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⁠.

⁠See what happened

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.

⁠Drive it from the command line

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.

⁠Things worth knowing

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.

⁠Security

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.

⁠Docker deployment

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⁠.

⁠Agent integrations

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

⁠Documentation

You want toRead
Start from zeroGetting started⁠
Configure providers, limits, logs, and harness layersConfiguration⁠
Build a WebSocket or REST clientAPI reference⁠
Add hard checks or model instructions around actionsHook configuration⁠
Run it outside a local Docker commandDeployment⁠

⁠What Peen is built with

Tag summary

Content type

Image

Digest

sha256:4ae5ce7c5…

Size

94.2 MB

Last updated

15 days ago

docker pull psyb0t/peen