Communication protocol
Pipe v0.50.5
Overview
The backend listens on both at once:
- A Unix socket (
/tmp/vps-agent.sock) – for clients on the same server (the Telegram bot) - TCP
127.0.0.1:7379– for an SSH tunnel from a laptop (CLI)
Communication uses a simple JSON lines protocol (one JSON line = one message). In Kubernetes mode kubectl port-forward svc/pipe 7379:7379 gives access to the TCP port.
Message format
Request – user message
{
"message": "message text",
"session_id": "uuid-per-user",
"interface": "cli:<user>|telegram:<user_id>|web:<user>",
"token": "secret-token",
"attachments": [{"name": "screenshot.png", "mime": "image/png", "data": "<base64>"}]
}
attachments is optional (files, photos, screenshots); with attachments message can be empty. Limits: 10 MB per file, 20 MB and 10 files per message; exceeding them or bad base64 ends with an error frame without calling the agent. The backend determines the kind from the content: an image goes to the model as image_url, text is pasted into the message (after secret redaction), other files are saved in DATA_DIR/uploads/ and the agent gets the path. A request line can be up to 32 MiB (the server's READ_LIMIT).
Request – confirming an operation
{
"confirm": true,
"session_id": "uuid-per-user",
"token": "secret-token"
}
Request – client command
The clients' "/" commands (Telegram, CLI) send {"command": ...}:
{
"command": "run_skill",
"session_id": "uuid-per-user",
"interface": "cli:kuba",
"name": "renew_certificate",
"args": "only for example.com",
"token": "secret-token"
}
command | Extra fields | Response |
|---|---|---|
list_skills | -- | one line with "data": {"skills": [{"name", "description", "command"}]} |
server_md | -- | "data": {"content": "..."} (empty when SERVER.md doesn't exist) |
scan_server | -- | streaming: the agent explores the server, creates/updates SERVER.md and DIRECTORY |
status | -- | server status without the LLM: a frame with the text in response (for older clients), then done with "data" like overview |
overview | -- | "data": {"hostname", "status": "ok|warning|critical", "headline", "facts", "gauges": [{"id", "label", "pct", "level": "ok|warn|crit", "value"}], "series": {"cpu", "mem", "disk"}, "alerts", "containers", "sites", "projects", "unread", "usage": {"tokens", "cost", "cached", "prompt", "saved", "calls"}, "text"} – the dashboard without the LLM |
feed | args: empty or seen (mark as read); optionally since (a timestamp) | "data": {"items": [{"id", "ts", "at", "type", "severity", "title", "text", "key", "kind", "actions": [{"index", "label", "kind", "command"?, "args"?, "admin"?, "primary"?}], "push", "held", "done", "unread"}], "unread", "seen", "text", "prefs"} – the inbox: what Pipe sent on its own |
feed_act | the message's id, action (index) | runs a button: snooze / mute / dismiss → "data": {"id", "done", "text"?, "muted"?}; message → a streamed conversation with the agent; command → like that command (cleanup, patch, investigate...). An action with admin – administrator only; an action already done (done) a second time → error |
prefs | optionally level (all|important|critical), quiet (22:00-07:00 or off), per_day (0-50), args: unmute <kind>|all | "data": {"level", "quiet", "per_day", "muted", "text", "can_edit"} – notification settings; changes by the administrator only |
install | args: skill|plugin <source> [filter]; then token + execute: true | /download without the LLM: a preview "data": {"token", "text", "skills", "servers", "empty"} (the repository is cloned and removed), after YES "data": {"text"}; the token is valid 15 min, single-use. Admin only; no MCP servers in observe mode |
mcp_servers | args: empty (state), add <catalog add-on>, add <name> <npx ...|uvx ...|docker run ...|URL> [VAR=${MCP_X}], remove <name>, enable|disable <name>, reload, key (with name = MCP_... and key = the value), forget <MCP_...> (also dodaj/usun/wlacz/wylacz/odswiez/klucz/zapomnij) | the /mcp window: "data": {"text", "servers": [{"name", "status", "error", "description", "source", "addon", "enabled", "runs", "version", "tools": [{"name", "read", "ask", "description"}], "secrets": [{"name", "set"}]}], "catalog": [{"name", "title", "description", "category", "homepage", "login", "installed", "ready", "runs", "secrets": [{"name", "label", "help", "url", "set"}]}], "can_edit"} – never secret values. Changes: admin only, adding and enabling not in observe mode |
web | args: empty (state), on [tunnel|direct] [port] [domain] / wlacz (tunnel = on tunnel), off / wylacz, approve <code> [viewer] / wpusc, deny <code> / odrzuc, revoke <number|name|all> / odwolaj; inbox buttons pass the request id and role | Pipe in the browser at a public address (core/webpublic.py, core/webaccess.py): "data": {"on", "url", "mode" (direct|tunnel), "devices", "pending", "text", "can_edit"}; on sends progress frames and adds ok. Everyone sees the state (devices and requests – administrator), changes are admin only. Without the LLM |
cleanup | -- | free up space without the LLM: progress frames, then "data": {"freed", "text", "steps", "skipped", "exit_code"}. Refused: viewer, observe mode |
projects | args: empty or logs / stop / start / remove (with execute: true, otherwise a preview), name | "data": {"projects": [{"name", "path", "url", "stack", "run", "status", "detail"}], "workspace", "text", "can_edit"}; with args – "data": {"name", "text"}. Changes: administrator, not in observe mode |
run_skill | name (skill name or command), optionally args | streaming |
diagram | optionally args (title) | infrastructure map without the LLM: a frame with attachment, then done with "data": {"summary": "..."} |
directory | -- | "data": {"entries": [{"path", "kind", "description", "remote", "branch", ...}], "text": "..."} |
new_chat | -- | "data": {"cleared": true|false}: removes the session history from memory and disk (/new); the server memory stays |
vibe | args: empty or reset | "data": {"content": "..."} or {"content": "", "reset": true|false} (the VIBE of the user from interface) |
alerts | -- | "data": {"active": [alert], "recent": [event], "enabled": bool} |
targets | -- | "data": {"targets": ["target description", ...]} |
routines | -- | "data": {"routines": ["routine description", ...]} |
skill | name (name or command) | "data": {"name", "description", "content", "command"} – the skill's content for preview |
graph | -- | "data": {"root", "hostname", "views": {"fleet" | "host" | "p:<project>": {"title", "parent", "nodes": [{id, kind, label, sub, state, opens?, meta, alerts?}], "edges": [{from, to, kind, label?}]}}, "index": {id: [node on each level]}} – the map as data (web interface) |
journal_changes | id | "data": {"id", "command", "status", "undoable", "files": [{path, status: added|deleted|modified|unchanged|unknown, diff, note}], "inverse", "notes"} – the "before → now" diff from the journal backup, secrets redacted |
providers | -- | "data": {"providers": [{id, name, model, default_model, requires_key, has_key, key_source: web|env|"", ready, key_url, notes, base, active}], "active": {id, name, model}, "chosen": bool, "can_edit": bool} – LLM providers without keys; ready = has a key |
provider_models | name (provider id), key (optional – a new key to check) | "data": {"models": [...], "total"} – the provider's current chat models; a rejected key = error. Admin only |
provider_set | name, key (optional), model (optional) | like providers – saves the key (after checking it with the provider) and the model, switches the agent, sets chosen. Admin only |
provider_forget | name | like providers – removes a key added from the interface; a provider without a key stops being active. Admin only |
yolo | args: on | off (optional; also wlacz/wylacz, tak/nie) | "data": {"yolo": bool, "can_edit": bool, "text"} – without args the state, with args turns YOLO mode of this session on/off: operations that need confirmation run right away (through the safety fuse and the journal; instead of [POTWIERDZ] the stream carries a progress event "YOLO — ..."). Admin only; doesn't survive a backend restart |
view | args: simple | technical (optional; also prosty, techniczny or a plain-words description, e.g. "reminders and emails") | "data": {"view": "simple" | "tech", "chosen": bool, "views", "can_edit": bool, "text"} – without args the state, with args sets the view of the whole agent (DATA_DIR/view.json, core/views.py); subscribers get a view event. An unknown description → an error naming both views. Admin only |
language | args: pl | en (optional; also polski, english, angielski) | "data": {"lang", "chosen": bool, "languages": ["pl", "en"], "can_edit": bool, "text"} – without args the state, with args switches the language of the whole agent (prompts, tool descriptions, reports, messages) without a restart and saves the choice in DATA_DIR/language.json; subscribers get a language event. Admin only |
reminders | cancel: id (optional), claim: true (optional) | "data": {"text", "reminders": [{id, due, kind, text, fired}], "claimed": [reminder events], "cancelled"} – claim picks up reminders that fired while nobody was subscribed (CLI) |
history | -- | "data": {"entries": ["audit log line", ...]} (the last 15) |
update | args: empty or sprawdz / check | streaming: the agent calls the pipe_update tool (apply with confirmation or check) |
investigate | alert id | streaming: the agent investigates a watcher alert; the backend adds the server changes from the last day |
changes | args: period (24h, 3d) | "data": {"text": "...", "hours": 24} – what changed (snapshots), without the LLM |
chart | args: load|ram|dysk and a period (ram 7d) | a frame with attachment (PNG), then "data": {"summary", "metric", "image": bool} |
health | -- | "data": {"text": "..."} – certificates, sites, DNS, backups (checks without configuration) |
digest | -- | a frame with attachment (24 h load chart), then "data" like the digest event (below) |
mcp | rpc: one MCP JSON-RPC message | "data": {"rpc": response | null} – MCP bridge (CLI --mcp); identity and role from the token |
approvals | -- | "data": {"pending": [approval event]} |
approve | id, decision (bool) | admin only; approving runs the operation through the safety fuse, "data": {"id", "status", "text"} |
incidents | -- | "data": {"text", "incidents": [...]} – incident memory (what happened, findings, what helped) |
transcribe | audio (base64, max. 10 MB; OGG, WAV, MP3...), filename | "data": {"text": "..."} – voice message transcription (STT) |
audit | -- | "data": {"score", "grade", "findings": [{"id", "severity", "title", "detail", "fix", "command", "host_only"}], "passed", "unknown", "text"} – without the LLM |
welcome | -- | a frame with attachment (map), then "data" like the welcome event |
journal | -- | "data": {"entries": [{"id", "summary", "undoable", "status"}], "text": "..."} – change journal |
vulns | -- | "data": {"text", "view"} – the vulnerability report in Markdown (view: the same as data for pipe web, vulns.report_view) (packages, remote servers, images, applications) with explanations; the model writes missing ones (at most EXPLAIN_WAIT s), without a model the OSV description |
patch | scope: empty / all (everything), images/obrazy, system/serwer, servers/serwery, an ssh target name, urgent/pilne (this server's urgent packages) | patches right away (core/patching.py): progress frames with the steps, then a new scan and "data": {"text", "exit_code", "entry_id", "entries", "scope", "summary", "fixed", "unchanged", "freed", "output", "report", "parts"} (unchanged – unchanged by the install, not tried again for a day; freed – bytes freed by the cleanup; report – the state after the patch, like view from vulns). Nothing to patch: "data": {"nothing": true, "text", ...}. The server builds the commands from the current scan. Patching runs in the background: a second patch meanwhile gets the same progress and result, and after the client disconnects the result arrives as a message reminder. The execute and patch_token fields (the preview before v0.38) are ignored. Refused: viewer, observe mode; this server's packages and images only in native and docker mode (with the helper container). Without the LLM |
patch_status | -- | after a lost connection: the progress and result of the running or just finished patch, like patch itself; "data": {"lost": true} when there is nothing to collect. Starts nothing. Without the LLM |
undo | optionally id; execute: true | without execute: "data": {"id", "undoable", "preview"} (the last entry to undo when there is no id); with execute and id: the undo, "data": {"id", "text"}. Without the LLM |
usage | -- | "data": {"today", "history", "month", "text"} – LLM tokens and cost |
subscribe | since: ts of the last event seen (optional) | the connection stays open; first the events published after since, then watcher events (below) until the client disconnects |
The backend builds the message for the agent (scan_server, run_skill, investigate, a message action from the inbox); the client sends only the command. Messages built by the backend don't teach VIBE. A skill's command field is a name that follows Telegram's rules (lowercase letters, digits, _, up to 32 characters). It is empty when the skill name is reserved (status, mapa, server, skille, katalogi, vibe, alerty, cele, rutyny, zmiany, wykres, zdrowie, raport, koszt, cofnij, dziennik, audyt, ...) or collides with another skill after shortening.
Response
{
"response": "response text",
"status": "ok|confirm|error",
"done": false
}
The server sends many JSON lines (streaming). The last line has "done": true. A frame can additionally contain (then response is empty, so older clients skip it):
Attachment – a file for the user, for example a diagram:
{"response": "", "status": "ok", "done": false, "attachment": {
"name": "infrastructure-map.png", "mime": "image/png", "caption": "Infrastructure map",
"data": "<base64>", "source": "<Mermaid source>", "text": "<ASCII preview or empty>"}}
Progress – an intermediate status (for example what a worker is doing), to show live; it isn't part of the answer:
{"response": "", "status": "ok", "done": false, "event": {"type": "progress", "text": "web-1 $ uptime", "source": "worker:web-1"}}
Frames with diagrams are hundreds of KB, so the client must read lines with a larger limit than asyncio's default 64 KiB (open_connection(..., limit=32 * 1024 * 1024)).
Watcher events (subscribe)
After {"command": "subscribe"} the server sends {"event": {"type": "subscribed", "lang": "pl|en", "lang_chosen": bool, "view": "simple|tech", "view_chosen": bool, "ts": 1761679200.5}} (lang_chosen – the language was chosen with the language command, and the client should adopt it; view_chosen – the same for the view), and then, without done:
{"response": "", "status": "ok", "done": false, "event": {"type": "language", "lang": "en", "at": "..."}}
(Pipe's language was changed from any channel, so the client switches its messages; the event doesn't go into the alerts history; likewise {"type": "view", "view": "simple"} after a view change)
{"response": "", "status": "ok", "done": false, "event": {
"type": "alert", "id": "3f2a9c1d0b", "key": "disk:/", "severity": "warning|critical",
"title": "Disk / is 93% full", "detail": "3.1 GB free of 40 GB (/dev/sda1)",
"since": "2026-09-24 14:02", "state": "new|resolved|event", "history": "", "at": "2026-09-24 14:02"}}
{"response": "", "status": "ok", "done": false, "event": {
"type": "routine", "name": "morning-review", "status": "OK|PROBLEM", "report": "...", "at": "..."}}
{"response": "", "status": "ok", "done": false, "event": {
"type": "reminder", "id": "6d1546", "kind": "message|task", "text": "...", "report": "",
"to": "telegram:123", "set_at": "2026-10-01 20:02", "due": "2026-10-01 20:02:42", "at": "..."}}
{"response": "", "status": "ok", "done": false, "event": {
"type": "activity", "id": "<tool call id>", "phase": "start|wait|end", "tool": "execute_command",
"label": "docker restart web", "nodes": ["c:web"], "ok": null, "entry": "", "workers": {},
"output": "web", "full": "...", "code": 0, "auto": true}}
Every client gets activity events in a conversation stream (except the viewer role); they say what the agent is doing and on which nodes of the map (graph). wait = waiting for confirmation; end has ok (true/false/null after a refusal) and entry, the journal entry id when the change was made. For commands (execute_command, remote_exec, git_command, cron_manage, write_file) end also carries a fragment of the output: output (up to 3 lines: on an error the error lines and the last one, on success the end, plus a safety fuse alarm), full (up to 60 lines / 4000 characters, to expand) and code (the exit code). It is the same result the model got, already with secrets redacted; in the simple view these fields are absent. auto: true – a change made without asking (autonomy, docs/security.md). A client that knows only progress can skip activity events.
to is the interface that set the reminder; the client decides whom to show it to. For kind: task the report field contains the worker's report.
{"response": "", "status": "ok", "done": false, "event": {
"type": "digest", "title": "Report vps1 -- 29.09.2026 07:00", "text": "<everything as text>",
"sections": [{"title": "Health", "lines": ["..."]}, {"title": "Changes since yesterday", "lines": ["..."]}],
"attachment": {"name": "load-chart.png", "mime": "image/png", "data": "<base64>", "...": "..."}, "at": "..."}}
{"type": "investigation", "key", "title", "report", "auto", "alert_id", "severity", "fix_message"} – the findings of a worker that investigated a critical watcher alert (AUTO_INVESTIGATE) or a webhook alert (WEBHOOK_INVESTIGATE=1) by itself; both go through one Watcher.auto_investigate(), and fix_message is the text of the Fix it button. history in an alert – the last occurrence of the same problem (incident memory).
{"type": "insight", "key", "kind", "severity": "info|warning", "title", "text"} – a suggestion without the LLM (disk full forecast, space to reclaim, container restarts, a pending server reboot, growing RAM). {"type": "notice", "title", "text"} – a summary of what waited during quiet hours. {"type": "login", "id", "code", "device", "ip", "title", "text"} – someone at the /web page asks for access; the inbox buttons (Let in, View only, Reject) call the web command. Always notified, administrators only.
The inbox. alert, insight, digest, welcome, routine, investigation and notice events land in the inbox and also carry feed_id, push (whether to notify now: level, quiet hours, the suggestion limit), held (waits for the end of quiet hours) and actions (buttons: index, label, kind, admin...). A client notifies only on push: true, and a button sends feed_act with feed_id and index. No push (an older backend) = notify.
{"type": "approval", "id", "command", "target", "requested_by", "reason", "plan", "status"} – an external agent (MCP) is waiting for admin approval; the client shows buttons and sends approve.
{"type": "welcome", "title", "sections", "score", "grade", "text", "attachment"?} – once after installation, when the first subscriber connects.
state: new – a new or escalated alert, resolved – the problem is gone, event – a one-off event (a new public port, a stopped container). investigate with an id asks the agent to investigate the alert. Alert keys have a group prefix: disk:, memory, load, container:, port: (every WATCH_INTERVAL) and cert:, site:, dns:, backup: (checks without configuration, every CHECKS_INTERVAL). vuln:system – urgent fixable vulnerabilities in system packages, vuln:images – in container images, vuln:servers – on remote servers (ssh targets), vuln:apps – critical ones in application dependencies (persistent; sent again when a new one appears). hook:<source>:<name> – webhook alerts (persistent until a resolved message). auth:ssh (a series of failed logins) is persistent; security:<section>:<key>, auth:breach:... and auth:login:... are one-off events. digest arrives once a day at DIGEST_TIME.
Token authorization
The token field is required when any token is configured: AGENT_TOKEN (admin role), AGENT_VIEWER_TOKEN (viewer role) or client tokens (python3 -m backend.tokens add NAME --role admin|viewer, with only the SHA-256 hash in data/tokens.json). Without any token the backend accepts every request (admin role).
The viewer role (read only): {"confirm": true} and undo with execute are refused, tools that change state return a refusal to the model (the client never gets a YES question). A session belongs to the identity that created it (the admin token, the viewer token or a named token); a request with another token and the same session_id gets This session belongs to another client.
The token is checked for every request type, including confirmations (confirm), so an unauthenticated client can't approve an operation pending in someone else's session. With a wrong token the backend answers:
{"response": "Error: invalid authorization token.", "status": "error", "done": true}
Response states
| Status | Meaning |
|---|---|
ok | A normal agent response |
confirm | The agent is waiting for the user's confirmation |
error | An agent error or a refusal |
Session flow
- The client connects (Unix socket or TCP)
- The client sends a request (a JSON line +
\n) - The server answers with one or more JSON lines
- The last line has
"done": true - If
"status": "confirm", the client asks the user for confirmation - The client sends
{"confirm": true|false, "session_id": "..."} - The server continues processing and sends further responses
Python implementation
import asyncio
import json
async def chat_with_agent(message: str, session_id: str) -> list[dict]:
reader, writer = await asyncio.open_unix_connection("/tmp/vps-agent.sock", limit=32 * 1024 * 1024)
request = {"message": message, "session_id": session_id, "interface": "my-client"}
writer.write(json.dumps(request).encode() + b"\n")
await writer.drain()
responses = []
while True:
raw = await reader.readline()
resp = json.loads(raw)
if resp.get("attachment"):
save(resp["attachment"]) # e.g. base64 -> PNG file
elif resp.get("event"):
show_progress(resp["event"])
else:
responses.append(resp)
if resp.get("done"):
break
writer.close()
return responses
Requirements for new clients
- One
session_idper user (for examplestr(user_id)) - Send
interface(<client>:<user>); it identifies the user in the audit log and in VIBE - Handle the
confirmstatus: ask the user and send the answer - Handle
attachment(show the image or save the file) andeventof typeprogress(show it live) - Read lines with a limit >= 32 MiB
- Handle long answers: split them into smaller messages if the platform has a limit
- Alerts: a separate, permanent connection with
subscribeand reconnecting after a disconnect; send"since"with thetsof the last event you saw, and the server first sends what you missed (e.g. the/updateresult, which arrives right after the backend restarts).pipe webdoes this with the SSEid:/Last-Event-ID
Webhooks (HTTP)
A separate port (WEBHOOK_PORT, off by default) that accepts alerts from other systems:
POST /hook/alertmanager | /hook/grafana | /hook/uptime-kuma | /hook/github | /hook/generic
Authorization: Bearer <WEBHOOK_TOKEN> (or ?token=..., or GitHub's X-Hub-Signature-256 signature)
Content-Type: application/json
202 {"ok": true, "alerts": 2, "events": 1}
GET /health -> 200 {"ok": true}
Generic format (/hook/generic): {"title", "message", "severity": "critical|warning", "status": "firing|resolved", "name"}. Body limit 1 MiB. Without WEBHOOK_TOKEN the webhook server doesn't start.