npm package: @m64/nats-pi-bridge
Description: Standalone headless service that spawns and manages PI coding agent sessions on demand via NATS. Control plane / data plane split, streaming wire protocol, multi-session microservice registration.
Author: m64
Latest version: 0.0.4

# nats-pi-bridge

Standalone headless service that spawns and manages PI coding agent sessions on demand via NATS.

> **Zero PI install required.** The bridge embeds the full [`@mariozechner/pi-coding-agent`](https://github.com/badlogic/pi-mono) runtime as library code — agent loop, tool execution (read/write/edit/bash), session management, model registry, all in-process. One `npx @m64/nats-pi-bridge` command gives you a fully operational PI worker addressable over NATS. No global `pi` binary, no TUI, no interactive CLI. Ideal for Docker containers, CI runners, and remote hosts where a full interactive coding agent would be overkill.
>
> **All you need on the host:** Node.js ≥20.6, a NATS server (or `demo.nats.io`), and an API key for any supported model provider.

This bridge exposes session lifecycle + prompting as a `pi-exec` NATS microservice. Callers send structured JSON to a permanent **control** endpoint to create, list, and stop sessions. Persistent sessions get their own per-session subject for follow-ups using the standard streaming wire protocol shared with the sibling implementations:

- `nats-pi-channel` — PI extension for **interactive** PI sessions
- `nats-claude-channel` — Claude Code MCP channel
- `nats-channel` (OpenClaw) — OpenClaw NATS channel

## Quick start

No install, no setup files — just [Node.js](https://nodejs.org) ≥20.6 and the [nats CLI](https://github.com/nats-io/natscli). You'll be streaming agent output over NATS in under a minute.

> **Prerequisite:** An API key in `~/.pi/agent/auth.json`. Create it directly — PI doesn't need to be installed:
>
> ```bash
> mkdir -p ~/.pi/agent && cat > ~/.pi/agent/auth.json <<'EOF'
> {"anthropic": {"type": "api_key", "key": "sk-ant-YOUR-KEY-HERE"}}
> EOF
> ```
>
> Swap `anthropic` for any other provider (`openrouter`, `openai`, `google`, …). If you already have PI installed locally, `pi /login` populates this file for you automatically.

### 1. Run the bridge

In one terminal:

```bash
npx @m64/nats-pi-bridge
```

That's it. It connects to `demo.nats.io` by default and registers a `pi-exec` NATS microservice with a control endpoint on `agents.pi-exec.$USER`:

```
pi-exec: connecting to demo.nats.io (context: default)
pi-exec: connected
pi-exec: control registered on agents.pi-exec.yourname
```

### 2. Fire a one-shot prompt

In a second terminal:

```bash
nats --server demo.nats.io req agents.pi-exec.$USER \
  '{"sessionMode":"run","body":"Write a haiku about NATS","cwd":"/tmp"}' \
  --wait-for-empty --timeout 60s
```

The agent's reply streams back chunk-by-chunk and an empty message closes the stream. The session is disposed the moment it's done — fire and forget.

### 3. Keep a session alive across prompts

Now create a persistent session, prove it remembers context with a follow-up, then stop it cleanly:

```bash
# Start the session + run the initial prompt
nats --server demo.nats.io req agents.pi-exec.$USER \
  '{"sessionMode":"session","body":"Pick a random 3-digit number and remember it","cwd":"/tmp","sessionId":"demo"}' \
  --wait-for-empty --timeout 120s

# See it in the live session list
nats --server demo.nats.io req agents.pi-exec.$USER \
  '{"sessionMode":"list"}' --timeout 5s

# Follow-up on the per-session subject — proves the session remembers
nats --server demo.nats.io req agents.pi-exec.$USER.demo \
  "What number did you pick?" --wait-for-empty --timeout 60s

# Stop it cleanly — the per-session instance disappears from `nats micro list`
nats --server demo.nats.io req agents.pi-exec.$USER \
  '{"sessionMode":"stop","sessionId":"demo"}' --timeout 5s
```

Ctrl-C the bridge when you're done. Every active session is disposed during graceful shutdown.

## Architecture

The bridge runs as a single Node.js process. The `pi-exec` service has:

- **Control instance** (permanent): `agents.pi-exec.<owner>` — control plane
- **Per-session instances** (dynamic): `agents.pi-exec.<owner>.<sessionId>` — data plane

Each session is its own NATS microservice instance (the same pattern that `nats-pi-channel` uses across processes — but here within one process). On stop, the session's instance is removed cleanly via `service.stop()`.

```
nats micro list
→ pi-exec │ 0.0.4 │ ABC123 │ control
          │       │ DEF456 │ worker-1 — /home/mario/code/nats-zig
          │       │ GHI789 │ sid-gpt — /home/mario/code/sid-gpt
```

## NATS server

The Quick Start uses `demo.nats.io` because it's zero-setup, but you'll usually want your own server. The bridge picks the server from a [NATS CLI context](https://docs.nats.io/using-nats/nats-tools/nats_cli#contexts) named via the `NATS_CONTEXT` environment variable.

### Localhost

```bash
# 1. One-time: create a context pointing at your local NATS
nats context save local --server nats://localhost:4222

# 2. Run the bridge against it
NATS_CONTEXT=local npx @m64/nats-pi-bridge
```

```
pi-exec: connecting to nats://localhost:4222 (context: local)
pi-exec: connected
pi-exec: control registered on agents.pi-exec.m64
```

> **Don't have a local NATS server yet?** The fastest way:
>
> ```bash
> docker run --rm -p 4222:4222 nats:latest
> ```
>
> Or grab a binary from the [nats-server releases](https://github.com/nats-io/nats-server/releases).

### Make a context the default

To avoid setting `NATS_CONTEXT` on every invocation, drop a one-line config file:

```bash
mkdir -p ~/.pi-exec
echo '{"context":"local"}' > ~/.pi-exec/config.json
```

After that, plain `npx @m64/nats-pi-bridge` connects to your chosen server.

### Authenticated and remote servers

The same context format works for any NATS auth scheme — credentials files, NKeys, JWTs, TLS, user/password. Anything `nats context save` can store, the bridge can use:

```bash
nats context save prod \
  --server nats://nats.example.com:4222 \
  --creds ~/.nkeys/creds/synadia/MyAccount/me.creds

NATS_CONTEXT=prod npx @m64/nats-pi-bridge
```

Context files live at `~/.config/nats/context/<name>.json`. See the [NATS CLI context docs](https://docs.nats.io/using-nats/nats-tools/nats_cli#contexts) for the full list of supported fields.

## Install

```bash
# Run without installing (what the Quick Start shows)
npx @m64/nats-pi-bridge

# Or install globally
npm install -g @m64/nats-pi-bridge
nats-pi-bridge
```

Requires Node.js ≥20.6 (pulled in via the PI SDK's engine requirement).

## Development

```bash
git clone <this repo>
cd nats-pi-bridge
npm install

# Run from TypeScript source (tsx)
npm start

# Watch mode
npm run dev

# Build the Node-compatible bundle into dist/
npm run build
node dist/server.js
```

## Configuration

Optional config file: `~/.pi-exec/config.json`

```json
{
  "context": "my-nats-context",
  "defaultModel": "anthropic/claude-sonnet-4-5",
  "defaultThinkingLevel": "off",
  "defaultMaxLifetime": 1800
}
```

Environment overrides:

| Var | Description |
|---|---|
| `NATS_CONTEXT` | NATS CLI context name (looks for `~/.config/nats/context/<name>.json`). If unset, connects to `demo.nats.io`. |
| `PI_EXEC_DEFAULT_MODEL` | Default model spec, e.g. `anthropic/claude-sonnet-4-5` |
| `PI_EXEC_DEFAULT_MAX_LIFETIME` | Default max session lifetime in seconds |

PI credentials are read from `~/.pi/agent/auth.json` (the standard PI agent location). Create the file directly — see the [Quick Start prerequisite](#quick-start) for the one-liner. If you have PI installed locally, `pi /login` populates it automatically.

## Control protocol

Send structured JSON to `agents.pi-exec.<owner>`:

```typescript
type ControlRequest = {
  sessionMode: "run" | "session" | "stop" | "list";
  body?: string;          // prompt text (run/session)
  cwd?: string;           // working directory (run/session) — resolved to absolute
  from?: string;          // caller identity (optional)
  sessionId?: string;     // REQUIRED for session and stop modes
  model?: string;         // e.g. "anthropic/claude-sonnet-4-5"
  thinkingLevel?: string; // off|minimal|low|medium|high|xhigh
  maxLifetime?: number;   // seconds, 0 = no expiry
};
```

| `sessionMode` | Required fields | Behaviour |
|---|---|---|
| `run` | `body`, `cwd` | Ephemeral: create session, prompt, stream chunks, dispose |
| `session` | `body`, `cwd`, `sessionId` | Persistent: create session + per-session NATS instance, prompt initial, stream chunks |
| `stop` | `sessionId` | Dispose session, remove instance |
| `list` | — | Return all active sessions as JSON |

Per-session prompt subject (`agents.pi-exec.<owner>.<sessionId>`) accepts the standard wire protocol: plain text or `{from, body}` JSON, streaming chunks back, empty payload terminates the stream.

## Testing

Start the bridge:

```bash
npm start          # from source
# or
node dist/server.js   # from the built bundle
```

In another terminal:

```bash
# Discoverability
nats micro list
nats micro info pi-exec

# Single-shot run
nats req agents.pi-exec.$USER \
  '{"sessionMode":"run","body":"What files are here?","cwd":"/tmp"}' \
  --wait-for-empty --timeout 120s

# Create persistent session
nats req agents.pi-exec.$USER \
  '{"sessionMode":"session","body":"List the project structure","cwd":"/path/to/project","sessionId":"worker-1"}' \
  --wait-for-empty --timeout 120s

# Follow-up via per-session subject
nats req agents.pi-exec.$USER.worker-1 "Now fix the failing tests" \
  --wait-for-empty --timeout 120s

# List sessions
nats req agents.pi-exec.$USER '{"sessionMode":"list"}' --timeout 5s

# Inspect a session
nats req agents.pi-exec.$USER.worker-1.inspect "" --timeout 5s

# Stop a session
nats req agents.pi-exec.$USER \
  '{"sessionMode":"stop","sessionId":"worker-1"}' --timeout 5s
```

## Error responses

All error responses follow `{"error": "<code>", ...context}`. Examples:

```json
{"error":"invalid_json", "message":"..."}
{"error":"invalid_request", "message":"sessionMode required"}
{"error":"invalid_sessionMode", "sessionMode":"explode"}
{"error":"missing_fields", "required":["body","cwd","sessionId"]}
{"error":"invalid_sessionId", "sessionId":"...", "message":"..."}
{"error":"session_exists", "sessionId":"...", "subject":"...", "message":"..."}
{"error":"session_create_failed", "message":"..."}
{"error":"not_found", "sessionId":"..."}
{"error":"shutting_down"}
```

Streaming modes (`run`, initial `session` prompt) emit text chunks + an empty terminator instead of a JSON wrapper. Errors during streaming are emitted as `error: <message>` text chunks followed by the empty terminator.