Ploinky Architecture
Technical architecture and implementation details of the Ploinky AI agent deployment system.
System Overview
Ploinky is built as a modular system with clear separation of concerns:
graph TD
UI["User Interface
CLI Commands / WebChat / Dashboard / Status"]
CLI["Ploinky CLI Core
Command Handler / Service Manager / Config"]
RS["Routing Server
+ Watchdog"]
WS["Web Services
WebChat / Dashboard / Status"]
RT["Runtime Mgmt
Container / Bwrap / Seatbelt"]
AS["Agent Sandboxes
Containers, bubblewrap processes, seatbelt jails"]
UI --> CLI
CLI --> RS
CLI --> WS
CLI --> RT
RS --> AS
WS --> AS
RT --> AS
Key Design Principles
- Isolation First: Every agent runs in its own container
- Workspace Scoped: All configuration is local to the project directory
- Zero Global State: No system-wide installation or configuration
- Git-Friendly: Configuration stored in .ploinky folder, can be gitignored
- Runtime Agnostic: Supports Docker, Podman, bubblewrap (Linux), and seatbelt (macOS) transparently
Core Components
CLI Command System (cli/commands/cli.js)
The main entry point that handles all user commands:
// Command routing structure
handleCommand(args) {
switch(command) {
case 'add': // Repository management
case 'enable': // Agent/repo activation
case 'start': // Workspace initialization
case 'shell': // Interactive container access
case 'webchat': // Web interface launchers
// ... more commands
}
}
Shared Utilities and Sandbox Backends
| Service | Responsibility |
|---|---|
cli/utils/workspace.js |
Manages .ploinky directory and configuration |
cli/sandbox/docker/ |
Container lifecycle management modules (runtime helpers, interactive commands, agent management) |
cli/utils/repos.js |
Repository management and agent discovery |
cli/utils/agents.js |
Agent registration and configuration |
cli/utils/security/secretVars.js |
Environment variable and secrets management |
cli/utils/config.js |
Global configuration constants |
cli/commands/help.js |
Help system and documentation |
cli/sandbox/bwrap/ |
Bubblewrap sandbox lifecycle management (Linux) |
cli/sandbox/seatbelt/ |
Seatbelt sandbox lifecycle management (macOS) |
agentRegistry.js |
Installed-agent lookup, principal resolution, runtime resources, and SSO-provider discovery |
dependencyCache.js |
Stamp-based node_modules cache validation |
dependencyInstaller.js |
npm install in containers with globalDeps merge |
profileService.js |
Workspace profile management (dev/qa/prod) |
workspaceDependencyGraph.js |
Agent dependency graph resolution |
Container Management
Container Lifecycle
Ploinky manages containers with specific naming conventions and lifecycle hooks:
// Container naming convention
// Pattern: ploinky_<repo>_<agent>_<project>_<cwdHash>
Volume Mounts
Each container gets specific volume mounts for security:
{
binds: [
{ source: process.cwd(), target: process.cwd() }, // Workspace
{ source: '/Agent', target: '/Agent' }, // Agent runtime
{ source: agentPath, target: '/code' } // Agent code
]
}
Runtime Detection
Outside a marked Ploinky box, runtime selection follows the manifest and host. Inside a marked box, every managed agent, helper, sidecar, probe, and install-container path uses nested Podman; Docker, bwrap, and Seatbelt are not fallbacks:
// Runtime preference: podman > docker
// Default: podman/docker
// Host sandbox:
// manifest "lite-sandbox": true → macOS: seatbelt, Linux: bwrap
// Container override:
// ploinky sandbox disable → podman/docker even for lite-sandbox agents
// Marked box:
// nested podman only for every Ploinky-managed container path
// Legacy string selectors such as "runtime": "bwrap" are rejected.
Routing Server
Purpose
The RoutingServer (cli/server/RoutingServer.js) is the sole HTTP/SSE/WebSocket edge inside a managed box. It owns public/control port 8080 and the un-published box-private port 8081, plus an unmounted Unix health socket. Its authorization unit is a validated immutable route-and-policy authorization generation, not a mutable candidate file or topology publication counter.
The outer wrapper always constructs exactly 127.0.0.1:<selected-router-host-port>:8080/tcp and 0.0.0.0:7882:7882/udp. Outer --port changes only the first mapping's physical-host side; --publish, --expose, and --listen-lan are rejected. No graph, manifest, profile, readiness result, environment value, label, or persisted state can add a third physical-host mapping.
Coordinated apply requires exact readable routing, policy, desired-state, enabled-agent, and manifest sources. Missing input is never substituted with an empty policy or registry. A genuinely fresh core command initializes all four persisted source documents together after creating the workspace directories and before bootstrap or registry mutation; generic environment setup does not create agents.json independently. A partial set or retained generation evidence stays inactive until explicit repair.
Rootless private transport: inside a marked Box, private 8081 binds the Box namespace wildcard so nested Podman can reach it through host.containers.internal:host-gateway. The outer runtime never publishes 8081, and every request remains fail-closed behind route policy, caller ACL, and a generation-bound private assertion. Outside a marked Box, private listeners retain exact loopback/managed-address binds.
// Conceptual activated generation (illustrative fields only)
{
"authorizationGeneration": "sha256:...",
"staticAgent": "explorer",
"routes": {
"explorer": {
"instance": "...",
"enableGeneration": "...",
"primaryTarget": "private-tcp-target",
"services": {
"dashboard": { "slug": "dashboard", "port": 3000, "externalPrefix": "/dashboard" }
}
}
},
"policyDigest": "sha256:..."
}
Request Flow
- Client sends a request through the loopback edge or an authenticated Cloudflare hostname.
- RoutingServer resolves listener class and the exact Host before examining the pathname.
- The selected host surface resolves one declared service and canonicalizes its external prefix.
- The provider and effective route policy independently admit the request against one immutable authorization generation.
- For a guest ingestion route, RoutingServer strips spoofable source and identity headers and creates one route-scoped opaque transport-source HMAC for per-source throttling; this value is never authorization or user identity.
- RoutingServer revalidates that captured authorization generation immediately before creating the upstream connection.
- RoutingServer proxies to that generation's private primary or explicit-port TCP target.
- Returns response to client
Agent Static Serving
The router no longer reads agent application files from the host filesystem. It owns workspace-global routes such as /workspace-files and the management surfaces, then proxies agent-prefixed requests to the selected agent. The agent receives the path with the mount prefix removed and is responsible for serving its own application files.
GET /index.html → 302 /<static-agent>/index.html
GET /explorer/index.html → explorer upstream /index.html
GET /explorer/assets/app.js → explorer upstream /assets/app.js
GET /workspace-files/doc.md → router workspace-file handler
The shared AgentServer serves static files from PLOINKY_CODE_DIR or /code after its built-in endpoints. Agents that use a custom manifest.agent command must implement equivalent static serving themselves.
Blob Storage API
The router exposes a simple blob storage API for large files with streaming upload/download.
// Upload (streaming)
POST /blobs/<agentName>
Headers:
Content-Type: application/octet-stream
X-Mime-Type: text/plain # optional; falls back to Content-Type
X-File-Name: report.pdf # optional; original filename for metadata
Body: raw bytes (streamed)
Response: 201 Created
{ "id": "", "url": "/blobs/<agentName>/", "size": N, "mime": "text/plain", "agent": "", "filename": "report.pdf" }
// Download (streaming, supports Range)
GET /blobs/<agentName>/<id>
HEAD /blobs/<agentName>/<id>
- Streams bytes from <agentWorkspace>/blobs/<id> with metadata from .../blobs/<id>.json
- Sets Content-Type, Content-Length, Accept-Ranges, and supports partial responses (206)
Watchdog Supervisor
The Watchdog (cli/server/Watchdog.js) supervises the RoutingServer process:
- Circuit breaker: Max 5 restarts in 60 seconds; trips and halts on breach.
- Exponential backoff: Initial 1s, 2x multiplier, max 30s between restarts.
- Health checks: Detailed probes use the supervisor-only unmounted Unix socket; no unauthenticated TCP health route is exposed.
- Container monitoring: Polls every 5 seconds using one runtime container-name inventory for all OCI targets. If that shared inventory fails, the monitor logs the failure and defers every OCI state decision for the tick; it does not amplify a control-plane outage with per-target runtime calls or false restarts.
- Recurring semantic ownership: Repeats manifest liveness and default-continuous readiness probes on a bounded interval;
health.readiness.continuous: falsekeeps a full readiness attestation activation-only. Probe timeouts terminate an exact process session under an OCI init reaper and cleanup failure is fatal. Semantic failure inactivates routing and forces a fresh exact-generation replacement rather than trusting one startup success. - Manual activation: Agents with
startup: manualare monitored only after explicit activation has created a route; stopped manual agents lose stale routes during general startup. Continuing a task is explicit activation of its stored provider: the selected CLI uses the router-owned Marketplace global enable lifecycle and waits for readiness before resuming the task. - Maintenance coordination: Defers automatic container restarts when the same container has an active maintenance lock, including timers scheduled before reinstall or explicit restart began.
- Logging: Structured JSON to
.ploinky/logs/watchdog.log.
Workspace System
Directory Structure
.ploinky/
├── agents.json # Enabled agents registry (+ _config key)
├── .secrets # Environment variables and secrets
├── profile # Active profile name (dev/qa/prod)
├── ploinky_history # CLI command history
├── repos/ # Cloned agent repositories
│ ├── basic/
│ ├── demo/
│ └── ...
├── agents/ # Per-agent work directories
├── code/ # Symlinks to agent code
├── skills/ # Symlinks to agent skills
├── logs/ # Router and watchdog logs
├── shared/ # Shared data between agents
├── running/ # PID files
├── routing.json # Candidate route input; inert until coordinated apply
├── edge-generations/ # Immutable validated route-and-policy generations
├── topology/ # Box-owned non-secret topology generations
├── servers.json # Web surface config (ports, tokens)
└── deps/ # Dependency caches
├── global/ # Global node_modules per runtime key
└── agents/ # Per-agent node_modules per runtime key
Agent Registry (agents/)
JSON file storing enabled agents and their configuration:
{
"ploinky_project_abc123_agent_demo": {
"agentName": "demo",
"repoName": "demo",
"containerImage": "node:18-alpine",
"createdAt": "2024-01-01T00:00:00Z",
"projectPath": "<workspace>/.data/demo",
"type": "agent",
"config": {
"binds": [...],
"env": [...],
"ports": [{"containerPort": 7000}]
}
}
}
Configuration Management
Workspace configuration persists across sessions:
// Stored in agents/_config
{
"static": {
"agent": "demo",
"port": 8088
}
}
Security Model
Container Isolation
- Filesystem: Manifests receive only their declared, profile-resolved mounts; writable code and host paths remain trusted capabilities.
- Network: Managed bridge launches use exactly
--hosts-file=none --add-host host.containers.internal:host-gateway; this is fixed transport configuration, not a capability. Host mode separately requires an exact effective-instance/current-generation capability and is not proof of authorization. Only one current capability owner may bind reserved UDP7882. - Process: Container, bubblewrap, and Seatbelt reduce exposure but do not create a hostile multi-tenant boundary inside one operator workspace.
- Edge: Physical-host TCP is loopback-only; public HTTP is an outbound Cloudflare tunnel to Router. Private Router and support listeners are never outer-published.
Secret Management
Environment variables stored in .ploinky/.secrets with aliasing support:
API_KEY=sk-123456789
PROD_KEY=$API_KEY # Alias reference
DATABASE_URL=postgres://localhost/db
Authentication Modes
Each agent can be independently configured with one of three auth modes via enable agent --auth none|pwd|sso:
- none: No authentication (default).
- pwd (local): Username/password auth with HMAC-signed JWT sessions. Cookie:
ploinky_jwt. Session TTL: 4 hours. - sso (OIDC): Delegates to the configured SSO provider agent marked with
"ssoProvider": true. Supports PKCE flow. Cookie:ploinky_sso.
Request-Bound Identity and Policy
Ploinky derives a distinct HMAC secret for each agent. User Sessions, Agent Assertions, Router Requests, and the distinct private-service assertion profile are direction-typed, short-lived, replay-protected, and bound to method, canonical path, and request content. A private assertion additionally binds the exact effective instance and current enable generation, but is never a user or administrator credential.
Exact Host and listener class select a closed route surface before path dispatch. All HTTP, SSE, and WebSocket authorization uses one immutable route-and-policy authorization generation and revalidates its lease immediately before dialing. Every TCP admin/control/status handler requires a real local-admin session, and mutations additionally require exact Origin/CSRF validation.
Cloudflare, Topology, and TURN
The pinned in-box cloudflared process exists only in complete Cloudflare mode and always uses http://127.0.0.1:8080 as origin. Local-only mode starts no connector. A coordinated apply already selects credential-absent state as local-ready, so the publication supervisor adopts that exact generation without a duplicate route apply; a previous Cloudflare ownership journal still requires fail-closed teardown and coordinated commit. Ploinky creates neither quick tunnels nor tunnels, and keeps connector/API credentials out of argv, status, diagnostics, and ordinary agent environments.
The box-owned topology carries only non-secret logical locators and distinguishes the immutable route-and-policy authorization generation, a content-derived configuration generation, and a monotonic readiness/publication generation. The authenticated no-store browser projection returns one active locator plus configuration/publication ids, never the authorization id or inventory. TURN long-term credentials remain in core; exact current-generation consumers receive only short-lived brokered credentials and their expiry.
Web Services Architecture
WebChat (cli/server/handlers/webchat/)
Chat interface for CLI programs:
index.jsauthenticates and dispatches WebChat requests.- Conversation, upload, workspace-suggestion, message-envelope, and runtime responsibilities are isolated in dedicated modules.
- Captures stdout/stdin through the selected agent's TTY implementation.
- Uses HTTP input requests and server-sent events for real-time output.
- WhatsApp-style UI with message bubbles
- Automatic reconnection handling
Monitoring contracts
Ploinky exposes authenticated, administrator-only resource and log streams. Presentation belongs to API consumers; the runtime contains no monitoring UI, command execution, or runtime controls.
WebSocket Protocol
// Message types
{ type: 'input', data: 'user command' } // User input
{ type: 'output', data: 'program output' } // Program output
{ type: 'resize', cols: 80, rows: 24 } // Terminal resize
{ type: 'ping' } // Keep-alive
Data Flow Examples
Starting an Agent
1. User: enable agent demo
→ Find manifest in repos/demo/demo/manifest.json
→ Register in .ploinky/agents.json
→ Generate container name
2. Host user: ploinky --port 8088 start demo
→ Select physical loopback 8088; forward fixed inner Router port 8080
→ Bind private 8081 to the Box namespace wildcard (exact addresses outside a Box), then start public/control 8080
→ Prepare recursive manifest repositories and resolve the planning graph without enabling processes
→ Capture an early inactive generation with exact identities and every retained route targetless
→ Run fatal static preinstall, then startup config providers
→ Abort the early lease, reload registry, re-evaluate retained runtime hashes, and rotate newly stale tuples
→ Capture the final inactive targetless generation and exact launch lease
→ Start blocking waves while preserving the final exact identities
→ Create/reuse private mappings for primary and explicit TCP service targets
→ Apply each blocking wave's resolved targets before readiness authorizes dependents
→ Start additional enabled agents outside the graph
→ Spawn detached no-wait helpers last without waiting for their completion
3. Container startup:
→ Pull image if needed
→ Mount volumes (workspace, code, Agent)
→ Set environment variables
→ Run agent command or supervisor
API Request Routing
1. Client: GET http://simulator.localhost:8088/apis/simulator/monty-hall
2. RoutingServer:
→ Resolve public listener plus exact host surface
→ Resolve and authorize against the captured immutable authorization generation
→ Revalidate that authorization generation immediately before dialing
→ Proxy to the private target selected by the service route
3. Agent Container:
→ Process request
→ Return response
4. RoutingServer:
→ Forward response to client
WebChat Session
1. User: webchat secret python bot.py
2. Router WebChat handler:
→ Start PTY for the selected agent CLI
→ Serve chat.html through the router
3. Browser connects:
→ Open the server-sent event stream
→ Authenticate through the router login flow
→ Reuse the workspace/agent runtime across browser tabs
→ Receive agent-owned conversation snapshots through session-state events
4. Message flow:
→ User types in chat
→ HTTP POST → Server → TTY stdin
→ Program output → TTY stdout → SSE → Browser
→ Display as chat bubble
Selected-root User Administration
A public agent-root host may select the closed user-admin Router surface. The immutable edge plan admits only the selected route key's users collection, one optional user-id child, and settings endpoint. Other agents and Router control paths remain absent from that host.
The route plan grants reachability only. The existing handler still requires a real local administrator session for reads and mutations, and every mutation still requires an exact Origin plus a session-bound CSRF proof.
Agent MCP Bridge
AgentServer (Agent/server/AgentServer.mjs) exposes capabilities through the Model Context Protocol (MCP) using Streamable HTTP at /mcp on the container port (default 7000).
Router ↔ Agent Communication
- RouterServer abstraction: RouterServer talks to agents through
cli/server/AgentClient.js, which wraps MCP transports. - MCP protocol: AgentClient builds a
StreamableHTTPClientTransporttowardshttp://127.0.0.1:<hostPort>/mcpand exposeslistTools(),callTool(),listResources(), andreadResource(). - Unified routing: Requests hitting
/mcpcarry commands such aslist_tools,list_resources, ortool. RouterServer fans these calls out to every registered MCP endpoint and aggregates the replies. - Per-agent routes: Agent-prefixed paths like
/<agent>/mcpprovide direct access when needed. On a public agent-root host with theagent-mcpcapability, the immutable edge generation admits only the selected root and its transitive active-manifest dependency closure; unrelated enabled agents and arbitrary dependency content are not selected. - Transport independence: RouterServer stays agnostic of protocol details; AgentClient encapsulates the MCP implementation.
Tools and Resources
Agents declare their MCP surface through a JSON file committed alongside the agent source code: .ploinky/repos/<repo>/<agent>/mcp-config.json. When the CLI boots an agent container it copies this file to /tmp/ploinky/mcp-config.json (also keeping /code/mcp-config.json for reference). The file can expose tools, resources, and prompts, and each tool is executed by spawning a shell command. AgentServer does not register anything if the configuration file is missing.
{
"tools": [
{
"name": "list_things",
"title": "List Things",
"description": "Enumerate items in a category",
"command": "node scripts/list-things.js",
"input": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "fruits | animals | colors"
}
},
"required": ["category"],
"additionalProperties": false
}
}
],
"resources": [
{
"name": "health",
"uri": "health://status",
"description": "Service health state",
"mimeType": "application/json",
"command": "node scripts/health.js"
}
],
"prompts": [
{
"name": "summarize",
"description": "Short summary",
"messages": [
{ "role": "system", "content": "You are a concise analyst." },
{ "role": "user", "content": "${input}" }
]
}
]
}
AgentServer pipes a JSON payload to each command via stdin. Tool invocations receive { tool, input, metadata }; resources receive { resource, uri, params }. Command stdout is forwarded to the MCP response, while non-zero exit codes surface as MCP errors.
MCP Task Queue
Longer running MCP tools are orchestrated through Agent/server/TaskQueue.mjs. Every tool execution is wrapped in a task object that captures the command, input payload, timeout hints, timestamps, and eventual result/error.
- Concurrency guard: AgentServer limits work in flight (default 10, overridable via
"maxParallelTasks"inmcp-config.json) and keeps the rest in a FIFO pending queue. - Durable state: Tasks are stored in
$PWD/.tasksQueue. On restart, pending items resume and previously running entries are rewound to pending so they execute again. - Per-task payloads: The queue injects a unique
taskIdinto the JSON delivered to each command so downstream scripts can correlate logs or offer a status channel. - Timeout + lifecycle: Tool definitions may specify
timeoutMs. The queue arms a timer, kills the underlying process if it runs too long, and marks the task as failed with a timeout message. - Response and live-log channels: Successful executions persist stdout as the MCP result and use stderr for the live task log. Logs are bounded unless the tool declares full retention. A structured stdout object containing
outputTextis reduced to that field before exposure; a validated continuation descriptor may be retained as result metadata without entering visible task output. Failures retain stderr and exit-code diagnostics.
Task Status Polling
The Router/CLI side uses Agent/client/MCPBrowserClient.js to follow long-running jobs.
- Polling endpoint: After a task is enqueued, the client hits
/<agent>/task?taskId=...(falling back to/getTaskStatus) every 30 seconds and adds a timestamp query parameter to avoid caching. - Incremental updates: Each status response (HTTP 200) updates the console only if the task status changed (pending → running → completed/failed). Terminal states stop the poller immediately.
- Error handling: Non-200 responses are logged and the poller keeps retrying (except
404 task not found, which stops polling and reports failure), so status checks continue even across transient outages.
AgentMcpClient.callTool() owns blocking task polling and returns the terminal result. Its separate callToolWithoutWait() path returns the initial task response and offers it to a registered process-local observer without starting client-owned polling. A compatible CLI uses that observer in terminal and WebChat modes to detach tasks, persist metadata and logs in its own workspace store, reattach ongoing work after restart, and continue polling through the router. In WebChat mode the CLI emits generic task envelopes. Ploinky validates them, keeps only volatile runtime state, broadcasts task-update over the existing EventSource stream, and inserts no task state into its own persistence. The session-owning CLI inserts one lightweight { type: "task", taskId } conversation item per started task after the active assistant placeholder. The browser resolves each id from the CLI's /tasks list into a compact task reference. Its link opens the authenticated HTML task view in the generic right-side iframe; the parent sends /task view, /task stop, or /task continue to the selected CLI and forwards matching stream updates through same-origin postMessage. The view does not create another EventSource or call task data/action REST APIs. A continuation keeps one CLI-owned local task id, increments turn, and ignores late prior-turn updates. The provider emits final answers through its live stream, and the CLI stores one final-range entry per completed turn without duplicating terminal output.
Both AgentMcpClient and MCPBrowserClient expose getAgentStatus() and ensureAgentRunning(). The latter reads Marketplace state first, avoids a repeated enable request when the target already runs, otherwise submits the existing enable_agent action, and waits for live runtime status before the caller invokes the target. Enable mode is caller-controlled: the clients include mode only when supplied and otherwise leave Marketplace's isolated default intact. Agent-side calls use request-signed assertions; browser-side calls reuse the authenticated session and therefore retain normal Marketplace administrator checks.
Performance Considerations
Container Optimization
- Reuse existing containers when possible
- Lazy image pulling
- Shared base layers between agents
- Volume mount caching
Network Efficiency
- Private in-box TCP targets avoid physical-host publication
- HTTP keep-alive for persistent connections
- WebSocket for real-time communication
- Request buffering and batching
Resource Management
- Explicit scoped cleanup through the separate core and host lifecycle commands
- PID file tracking for process management
- Log rotation for long-running services
- Memory-efficient streaming for large outputs