Skip to content

Instantly share code, notes, and snippets.

@sam-saffron-jarvis
Created February 24, 2026 01:42
Show Gist options
  • Select an option

  • Save sam-saffron-jarvis/39d39595ddddd15689a1ad45c73eeeba to your computer and use it in GitHub Desktop.

Select an option

Save sam-saffron-jarvis/39d39595ddddd15689a1ad45c73eeeba to your computer and use it in GitHub Desktop.
dv serve HTTP API spec

dv serve — HTTP API Spec

Overview

dv serve adds an HTTP API server to dv, exposing the full CLI surface over JSON + Server-Sent Events. The primary consumer is Jarvis (running in a Docker container), which reaches the host via host.docker.internal or the Docker bridge gateway. Any HTTP client works — curl, scripts, future web UIs.

Design principles:

  • Full CLI parity — every non-interactive command is reachable
  • Long-running operations stream via SSE so callers see live output
  • One auth primitive: bearer token (no sessions, no cookies)
  • Config state lives on the host as normal — dv serve reads/writes ~/.config/dv/config.json the same way the CLI does
  • enter and other TTY commands are intentionally excluded — they require an interactive terminal and have no meaningful async equivalent

Starting the server

dv serve [--port PORT] [--host HOST] [--token TOKEN]
Flag Default Description
--port 7373 TCP port to listen on
--host 127.0.0.1 Bind address. Set 0.0.0.0 to allow Docker bridge access
--token (auto) Bearer token. Auto-generated and saved to config on first run if omitted

On first run with no --token, dv serve generates a random 32-byte hex token, writes it to ~/.config/dv/config.json as serveToken, and prints it once. Subsequent runs read it from config automatically.

For Docker container access, the recommended invocation is:

dv serve --host 0.0.0.0

The Jarvis skill stores the token in a memory fragment and calls http://host.docker.internal:7373.


Auth

Every request must include:

Authorization: Bearer <token>

Missing or invalid token → 401 Unauthorized.


Response conventions

Success (non-streaming): 200 OK, Content-Type: application/json

{ "ok": true, "data": { ... } }

Error: 4xx / 5xx, Content-Type: application/json

{ "ok": false, "error": "human-readable message" }

Streaming: 200 OK, Content-Type: text/event-stream

SSE events:

event: output
data: {"stream":"stdout","text":"Building image...\n"}

event: output
data: {"stream":"stderr","text":"warning: ...\n"}

event: done
data: {"exit_code":0}

event: error
data: {"error":"container not running"}

The done event is always the final event. Clients should read until the connection closes or done is received. On error mid-stream, an error event is sent followed by connection close (no done).


Endpoints

Meta

GET /status

Health check and version info.

Response:

{
  "ok": true,
  "data": {
    "version": "0.1.0",
    "selected_container": "ai_agent",
    "selected_image": "discourse"
  }
}

Containers

GET /containers

List all containers belonging to the selected image. Equivalent to dv list.

Query params:

  • sessions=true — include active exec session counts (slower, matches dv list --sessions)

Response:

{
  "ok": true,
  "data": {
    "containers": [
      {
        "name": "ai_agent",
        "status": "Running",
        "time": "2 hours",
        "image": "ai_agent",
        "urls": ["http://localhost:4200"],
        "selected": true,
        "sessions": 2
      }
    ],
    "selected": "ai_agent"
  }
}

POST /containers

Create and start a new container. Equivalent to dv new (creates with name) or dv start (creates default).

Request body:

{
  "name": "my-agent",
  "image": "discourse",
  "host_starting_port": 4201,
  "container_port": 4200,
  "reset": false
}

All fields optional. name defaults to currentAgentName(cfg).

Streaming — streams build progress and startup output as SSE output events, then a done event with {"exit_code": 0}.


GET /containers/:name

Get state for a single container.

Response:

{
  "ok": true,
  "data": {
    "name": "ai_agent",
    "status": "Running",
    "time": "2 hours",
    "image": "ai_agent",
    "urls": ["http://localhost:4200"],
    "selected": true
  }
}

404 if container doesn't exist.


POST /containers/:name/start

Start an existing stopped container. Equivalent to dv start <name>.

Request body: (empty or {})

  • reset: bool — stop, remove, and recreate before starting

Streaming — SSE output events then done.


POST /containers/:name/stop

Stop a running container. Equivalent to dv stop.

Streaming.


POST /containers/:name/restart

Restart a container. Equivalent to dv restart.

Streaming.


POST /containers/:name/restart-discourse

Restart Discourse (unicorn/rails) inside the container without restarting the container itself. Equivalent to dv restart discourse.

Streaming.


DELETE /containers/:name

Remove a container. Equivalent to dv remove.

Request body:

{
  "remove_image": false,
  "force": false
}

Response: 200 { "ok": true } on success, 409 if container has active sessions and force is false.


POST /containers/:name/rename

Rename a container. Equivalent to dv rename <old> <new>.

Request body:

{ "new_name": "better-agent" }

Response: 200 { "ok": true }.


POST /containers/:name/select

Set this container as the selected agent. Equivalent to dv select <name>. Updates global config (SelectedAgent). Note: session-local state (pid-based) only applies to the host terminal — remote callers set global config only.

Response: 200 { "ok": true }.


Building images

POST /images/build

Build a Docker image. Equivalent to dv build [TARGET].

Request body:

{
  "target": "discourse",
  "no_cache": false,
  "build_args": ["KEY=VALUE"],
  "tag": "",
  "classic_build": false,
  "builder": "",
  "rm_existing": false
}

All fields optional. target defaults to cfg.SelectedImage.

Streaming — this is the main automation entry point for daily rebuilds. Docker build output streams as SSE output events in real time. Final done event includes exit_code.

Example daily automation use: Jarvis calls POST /images/build each morning, streams output to a Telegram message, then calls POST /containers/ai_agent/start with reset: true.


POST /images/pull

Pull a prebuilt image from Docker Hub. Equivalent to dv pull.

Request body:

{
  "image_name": "discourse",
  "tag": "",
  "rm_existing": false
}

Streaming.


GET /images

List configured images from cfg.Images.

Response:

{
  "ok": true,
  "data": {
    "images": [
      {
        "name": "discourse",
        "tag": "ai_agent",
        "kind": "discourse",
        "workdir": "/var/www/discourse",
        "selected": true
      }
    ],
    "selected": "discourse"
  }
}

Running commands

POST /containers/:name/run

Run an arbitrary command inside the container as discourse. Equivalent to dv run -- <cmd>.

Request body:

{
  "cmd": "bundle exec rails db:migrate",
  "workdir": "/var/www/discourse",
  "as_root": false,
  "env": { "RAILS_ENV": "development" }
}

Streaming — stdout/stderr interleaved as SSE output events.


POST /containers/:name/run-agent

Run an AI agent inside the container with a prompt. Equivalent to dv ra <agent> <prompt>. This is the primary Jarvis automation endpoint.

Request body:

{
  "agent": "claude",
  "prompt": "Fix the N+1 query in TopicQuery#latest",
  "raw_args": []
}

raw_args maps to the -- ARGS... passthrough (for flags/interactive modes). Omit prompt and provide raw_args: [] to get interactive mode — though interactive mode is only useful if the client implements a terminal bridge (not the intended use for Jarvis).

Streaming. Agent runs can take minutes. Keep the SSE connection open. The done event includes exit_code.


Extract / Import

POST /containers/:name/extract

Extract container changes to a local host repo. Equivalent to dv extract.

Request body:

{
  "path": "",
  "dir": "",
  "sync": false
}

path — optional container path to extract (absolute or relative to workdir). dir — override local repo destination path on host. sync — start a sync watcher (will stream continuously until client disconnects).

Streaming — git clone/reset/copy progress, then done.

Note: sync mode streams indefinitely. The connection stays open and output events arrive when files change. Client disconnect stops the watcher.


POST /containers/:name/extract/plugin

Extract a specific plugin. Equivalent to dv extract plugin <name>.

Request body:

{ "plugin_name": "discourse-ai" }

Streaming.


POST /containers/:name/extract/theme

Extract a specific theme. Equivalent to dv extract theme <name>.

Request body:

{ "theme_name": "my-theme" }

Streaming.


POST /containers/:name/import

Import a local host repo back into the container. Equivalent to dv import.

Request body:

{
  "repo_path": "/home/sam/discourse_src",
  "base": "main"
}

Streaming.


Git / Branch / PR

POST /containers/:name/branch

Check out a Discourse branch in the container and reset/migrate. Equivalent to dv branch <branch>.

Request body:

{
  "branch": "feature/my-fix",
  "no_reset": false,
  "new": false
}

new: true creates the branch if it doesn't exist on the remote.

Streaming.


POST /containers/:name/pr

Check out a GitHub PR in the container. Equivalent to dv pr <number>.

Request body:

{
  "number": 12345,
  "no_reset": false
}

Streaming.


POST /containers/:name/catchup

Rebase the container's branch onto origin/main and run migrations. Equivalent to dv catchup.

Request body: {}

Streaming.


Discourse-specific

POST /containers/:name/reset

Full Discourse reset (db:reset + migrate + seed). Equivalent to dv reset.

Request body:

{ "discourse_reset": false }

discourse_reset: true runs dv discourse-reset (deeper wipe, reseeds from scratch) rather than the standard db:reset.

Streaming. This is slow — can take several minutes.


GET /containers/:name/mail

Fetch recent development emails (MailHog or letter_opener). Equivalent to dv mail.

Response:

{
  "ok": true,
  "data": {
    "messages": [
      {
        "from": "...",
        "to": "...",
        "subject": "...",
        "body": "..."
      }
    ]
  }
}

GET /containers/:name/ps

Show active exec sessions in a container. Equivalent to dv ps.

Response:

{
  "ok": true,
  "data": {
    "sessions": [
      { "pid": 1234, "command": "ruby", "user": "discourse", "cpu": "2.1", "mem": "3.2" }
    ]
  }
}

Update / Upgrade

POST /containers/:name/update/agents

Update all AI agents inside the container (dv update agents). Runs npm/curl installs for codex, gemini, claude, aider, cursor, etc.

Streaming. Long — expect 2-5 minutes.


POST /containers/:name/update/discourse

Update Discourse inside the container (dv update discourse). Pulls latest Dockerfile, rebuilds.

Streaming.


POST /upgrade

Upgrade the dv binary itself on the host (dv upgrade).

Streaming.


Copy

POST /containers/:name/copy

Copy files between host and container. Equivalent to dv cp.

Request body:

{
  "direction": "host_to_container",
  "host_path": "/home/sam/myfile.rb",
  "container_path": "/var/www/discourse/myfile.rb"
}

direction is "host_to_container" or "container_to_host".

Response: 200 { "ok": true } on success. Non-streaming (files are small).


Config

GET /config

Return full config JSON. Equivalent to dv config show.

Response:

{
  "ok": true,
  "data": { ...Config struct... }
}

PATCH /config

Set one config key. Equivalent to dv config set KEY VALUE.

Request body:

{ "key": "discourseRepo", "value": "https://github.com/myorg/discourse.git" }

Valid keys: imageTag, defaultContainerName, workdir, hostStartingPort, containerPort, selectedAgent, discourseRepo, extractBranchPrefix.

Response: 200 { "ok": true }.


Logs

GET /containers/:name/logs

Stream live container logs (stdout/stderr of the container process, not Discourse app logs). Optional since query param (duration, e.g. "5m").

Streaming — SSE output events, streams until client disconnects.


GET /containers/:name/logs/unicorn

Stream the Discourse unicorn (Rails) log. Equivalent to tailing /var/www/discourse/log/unicorn.log inside the container.

Query params:

  • lines=50 — how many historical lines to include before streaming (default 50)

Streaming — SSE output events, streams until client disconnects.


GET /containers/:name/logs/ember

Stream the ember-cli log (log/ember-cli.log). Same params as unicorn.

Streaming.


Implementation notes

Streaming implementation

Use os/exec with cmd.StdoutPipe() + cmd.StderrPipe(), multiplex both to the SSE writer in goroutines. SSE flush after each event. The HTTP handler holds the response open until the command exits, then sends done.

For docker.ExecInteractive-style commands (which currently pipe directly to os.Stdout/os.Stderr), a new docker.ExecStream(name, workdir, envs, argv, stdout, stderr io.Writer) variant is needed that writes to arbitrary writers rather than os.Stdout. This is the main new primitive required.

Job concurrency

No artificial serialisation. Multiple streaming requests can run concurrently — callers are responsible for not starting two builds at once if that matters to them. A future /jobs endpoint could track active operations but that's out of scope for v1.

SSE keep-alive

Send a comment (: keep-alive\n\n) every 15 seconds on idle streaming connections to prevent proxy/load balancer timeouts. Relevant for long build and update agents runs.

Config path

dv serve uses the same xdg.ConfigDir() as all other commands — typically ~/.config/dv. No special server config file.

New file

All implementation lives in internal/cli/serve.go + registered in root.go. No other files need modification except adding docker.ExecStream to internal/docker/docker.go.


Jarvis skill outline

Once the server is running, the Jarvis dv skill provides:

Base URL: http://host.docker.internal:7373
Token: <stored in memory fragment>

Key operations Jarvis can automate:

Task Endpoint
Daily fresh build POST /images/buildPOST /containers/ai_agent/start (reset)
Run an agent task POST /containers/ai_agent/run-agent
Check what's running GET /containers
Extract and open PR POST /containers/ai_agent/extract → host-side git/gh
Watch for errors GET /containers/ai_agent/logs/unicorn
Update all agents POST /containers/ai_agent/update/agents
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment