Skip to content

Deployment Guide: Docker, Railway, HTTP API, and GraphQL ​

This guide covers running Animus in a container, deploying it to Railway, and using the HTTP and GraphQL transport APIs that the animus web surface exposes via installed plugins.

1. Containerizing Animus ​

Multi-stage Dockerfile ​

The recommended pattern downloads a prebuilt animus release binary (no in-image Rust compilation) and bakes required plugins into the image at build time so the container boots without network access.

dockerfile
# ---- stage 1: fetch the prebuilt animus binary ----
FROM debian:bookworm-slim AS animus-fetch
ARG ANIMUS_VERSION=v0.7.0-rc.55
ARG ANIMUS_TARGET=x86_64-unknown-linux-gnu
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
    && rm -rf /var/lib/apt/lists/*
RUN curl -fsSL -o /tmp/ao.tar.gz \
      "https://github.com/launchapp-dev/animus-cli/releases/download/${ANIMUS_VERSION}/ao-${ANIMUS_VERSION}-${ANIMUS_TARGET}.tar.gz" \
 && tar xzf /tmp/ao.tar.gz -C /tmp \
 && cp "/tmp/ao-${ANIMUS_VERSION}-${ANIMUS_TARGET}/animus" /usr/local/bin/animus \
 && chmod +x /usr/local/bin/animus

# ---- stage 2: runtime ----
# Use a glibc-new enough base. animus binaries built on Ubuntu 24.04 need
# GLIBC >= 2.39; debian:trixie ships 2.41.
FROM debian:trixie-slim
ENV DEBIAN_FRONTEND=noninteractive

# libdbus-1-3 + libssl3: animus links them for the secret backend and TLS.
# git + curl: plugin install uses them at build time.
# nodejs + npm: required if using the claude provider CLI.
RUN apt-get update && apt-get install -y --no-install-recommends \
        ca-certificates git curl libdbus-1-3 libssl3 \
    && rm -rf /var/lib/apt/lists/*

# The claude provider plugin shells out to the `claude` CLI. Install it if you
# use animus-provider-claude. Omit this block for other providers.
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \
    && npm install -g @anthropic-ai/claude-code && npm cache clean --force \
    && rm -rf /var/lib/apt/lists/*

# Run as non-root. The claude CLI refuses --dangerously-skip-permissions as root,
# which the claude provider uses for autonomous agent runs.
RUN useradd -m -u 10001 -s /bin/bash appuser
WORKDIR /app

COPY --from=animus-fetch /usr/local/bin/animus /usr/local/bin/animus
COPY .animus /app/.animus

RUN chown -R appuser:appuser /app
USER appuser
ENV HOME=/home/appuser

# Bake all required plugins into the image (no runtime network needed).
# --signature-policy=disabled: use this only when pulling from a registry
# whose cosign identity is not yet in animus's trusted-publisher list.
# Remove this flag once your plugin repos are listed in trusted-signers.yaml.
ARG SIG=--signature-policy=disabled

RUN animus plugin install launchapp-dev/animus-provider-claude  $SIG --allow-shadow-builtin --project-root /app \
 && animus plugin install launchapp-dev/animus-subject-default  $SIG --project-root /app \
 && animus plugin install launchapp-dev/animus-queue-default    $SIG --project-root /app \
 && animus plugin install launchapp-dev/animus-workflow-runner-default $SIG --project-root /app \
 && animus plugin install animus-ecosystem/animus-transport-http $SIG --project-root /app

# Confirm all required roles are satisfied before shipping the image.
RUN animus daemon preflight --project-root /app

COPY deploy/start.sh deploy/webapi-forward.js /app/deploy/
RUN chmod +x /app/deploy/start.sh
EXPOSE 8088
CMD ["/app/deploy/start.sh"]

Key build arguments:

ARGDefaultDescription
ANIMUS_VERSIONv0.7.0-rc.55Release tag to download from launchapp-dev/animus-cli
ANIMUS_TARGETx86_64-unknown-linux-gnuTarget triple; must match the container's glibc
SIG--signature-policy=disabledSignature policy flag passed to every plugin install

Plugin preflight and required roles ​

The daemon refuses to start when any of these roles is unsatisfied:

RoleDefault plugin
At least one providerlaunchapp-dev/animus-provider-claude (or another)
At least one subject_backend (any kind, since v0.5.20)launchapp-dev/animus-subject-default
workflow_runnerlaunchapp-dev/animus-workflow-runner-default
queuelaunchapp-dev/animus-queue-default

animus daemon preflight in the RUN layer above catches any missing role at build time, not at runtime.

Two escape hatches are available at daemon startup (not substitutes for a working plugin set in production):

  • --auto-install — install any missing required-role plugins from their pinned defaults before the daemon starts. Causes a runtime network fetch.
  • --skip-preflight — bypass the check entirely. The daemon will fail at the first plugin RPC if the plugin is really missing.

State and persistence ​

Scoped runtime state lives under ~/.animus/<repo-scope>/ inside the container (where ~ resolves to the running user's $HOME). This includes runs, artifacts, the queue database, secrets, and compiled workflow config.

Project-local config lives at .animus/ within the project directory (/app/.animus in the skeleton above).

Ephemeral (default): state is lost on container restart. Acceptable for stateless pipelines where subjects are owned by an external store and the daemon is purely a workflow executor.

Persistent (recommended for production): mount a volume over /home/appuser/.animus so state survives redeploys.

dockerfile
VOLUME ["/home/appuser/.animus"]

Or in docker run:

bash
docker run -v animus-state:/home/appuser/.animus my-animus-image

2. Running the Daemon in a Container ​

Use animus daemon run — not daemon start — in containerized environments. daemon run is the foreground verb and writes to stdout/stderr, which container runtimes collect as logs. daemon start forks a background process and exits, which makes the container exit immediately.

A minimal entrypoint (deploy/start.sh):

bash
#!/usr/bin/env bash
set -euo pipefail
ROOT=/app

# Start the daemon in the foreground (correct for containers).
# Preflight runs automatically; --auto-install installs missing required plugins
# from pinned defaults if any were omitted from the image.
animus daemon run --project-root "$ROOT" &
DAEMON_PID=$!

# Wait for the control socket to become healthy before spawning the transport.
for _ in $(seq 1 30); do
  animus daemon health --project-root "$ROOT" >/dev/null 2>&1 && break
  sleep 1
done

# Spawn the installed transport plugin. It binds 127.0.0.1:8080 (loopback).
animus web serve --project-root "$ROOT" &
WEB_PID=$!

# Publish the API to other containers through a forwarder that checks
# ANIMUS_WEBAPI_TOKEN (below). Listens on 0.0.0.0:${WEBAPI_PORT:-8088}.
node /app/deploy/webapi-forward.js &
FWD_PID=$!
trap 'kill "$DAEMON_PID" "$WEB_PID" "$FWD_PID" 2>/dev/null || true' EXIT

wait "$DAEMON_PID"

The transport always binds 127.0.0.1:8080 under animus web serve, so other containers can't reach it directly. A small forwarder in the same container publishes it: it listens on the container's interface, refuses requests without Authorization: Bearer $ANIMUS_WEBAPI_TOKEN, and forwards the rest to the transport (deploy/webapi-forward.js):

js
const http = require("http");
const SECRET = process.env.ANIMUS_WEBAPI_TOKEN || "";
const PORT = parseInt(process.env.WEBAPI_PORT || "8088", 10);
const TARGET = { host: "127.0.0.1", port: 8080 };
if (!SECRET) {
  console.error("ANIMUS_WEBAPI_TOKEN is not set; refusing to start");
  process.exit(1);
}
http
  .createServer((req, res) => {
    if (req.headers.authorization !== `Bearer ${SECRET}`) {
      res.writeHead(401, { "content-type": "application/json" });
      return res.end('{"error":"unauthorized"}');
    }
    const headers = { ...req.headers };
    delete headers.authorization;
    // The transport answers only requests addressed to this machine.
    headers.host = `${TARGET.host}:${TARGET.port}`;
    delete headers.origin;
    const upstream = http.request({ ...TARGET, path: req.url, method: req.method, headers }, (up) => {
      res.writeHead(up.statusCode || 502, up.headers);
      up.pipe(res);
    });
    upstream.on("error", () => {
      res.writeHead(502, { "content-type": "application/json" });
      res.end('{"error":"bad_gateway"}');
    });
    req.pipe(upstream);
  })
  .listen(PORT, "0.0.0.0");

Keep the Host rewrite: the transport refuses (403) any request whose Host isn't localhost, a 127.x.x.x address or [::1] (see Security model).

The daemon and the transport are co-located in one container here. For higher availability, split them: one container runs the daemon without a transport, another runs the transport pointed at the daemon's control socket.

Required environment variables at runtime ​

VariableRequiredDescription
ANTHROPIC_API_KEYYes (claude provider)Provider API key; injected into the plugin's env via process env
ANIMUS_WEBAPI_TOKENYes (forwarder)Bearer token the forwarder requires on every request
WEBAPI_PORTNo (default 8088)Port the forwarder listens on

Keychain unavailable in containers. The OS keychain is not available in typical container environments. Secrets must reach the daemon and its plugins via process environment variables. The keychain layer is a no-op when the process env already carries the value, so existing workflows work without change. See Secrets reference.

3. Deploying to Railway ​

railway.toml ​

Place this file alongside your Dockerfile:

toml
[build]
builder = "dockerfile"
dockerfilePath = "deploy/Dockerfile"

[deploy]
startCommand = "/app/deploy/start.sh"
restartPolicyType = "on_failure"
restartPolicyMaxRetries = 3

The HTTP transport plugin has no built-in authentication. Never assign the Animus daemon service a public Railway domain. Instead use Railway's private network (*.railway.internal) and front it with a separate authenticated proxy or gateway service.

┌──────────────────────────────────────────────┐
│  Railway project                             │
│                                              │
│  Service: animus  (PRIVATE — no public URL)  │
│  ┌────────────────────────────────────────┐  │
│  │ animus daemon run   (foreground)       │  │
│  │ animus web serve    (transport plugin) │  │  ← binds 127.0.0.1:8080
│  │ webapi-forward.js   (checks token)     │  │  ← listens 0.0.0.0:8088
│  └────────────────────────────────────────┘  │
│           ↑ Railway private network          │
│  Service: gateway  (PUBLIC)                  │
│  ┌────────────────────────────────────────┐  │
│  │ Hono / Express / nginx / etc.          │  │  ← public HTTPS domain
│  │ Verifies caller auth                   │  │
│  │ Adds Bearer header, forwards to        │  │
│  │ http://animus.railway.internal:8088    │  │
│  └────────────────────────────────────────┘  │
└──────────────────────────────────────────────┘

In the gateway service, reach the Animus webapi through the forwarder using the Railway private DNS name: http://<service-name>.railway.internal:8088. The gateway must add Authorization: Bearer <ANIMUS_WEBAPI_TOKEN> to every forwarded request.

Environment variables ​

Set these in the Railway dashboard under the private animus service:

VariableDescription
ANTHROPIC_API_KEYClaude provider API key (or the key for your chosen provider)
ANIMUS_WEBAPI_TOKENBearer token the forwarder requires on every request

On the public gateway service, set at minimum:

VariableDescription
ANIMUS_WEBAPI_URLhttp://animus.railway.internal:8088
ANIMUS_WEBAPI_TOKENMust match the value set on the animus service

Persistence on Railway ​

Add a Railway Volume mounted at /home/appuser/.animus (adjust the path to match your container user's $HOME). This persists scoped runtime state — queue, runs, artifacts — across redeploys.

If subjects must also survive redeploys, install a persistent subject backend plugin (e.g. a Postgres-backed backend) rather than relying on animus-subject-default, which stores subjects on the container's local disk.

4. The HTTP Web API ​

Installing the transport ​

bash
# Installs transport-http (required), transport-graphql, and web-ui (recommended).
animus plugin install-defaults --include-transports

# Or install only the HTTP transport:
animus plugin install animus-ecosystem/animus-transport-http

Starting the transport ​

bash
animus web serve

animus web serve discovers and spawns installed transport_backend and web_ui plugins. It does not start an in-process HTTP server — the plugin binds its own port. If no transport plugins are installed, the command exits non-zero and prints the install command. The transport reports its bound URL through the plugin's JSON-RPC initialize handshake.

Bind address ​

Under animus web serve the HTTP transport binds 127.0.0.1:8080. web serve passes no bind address and does not read a transports block from .animus/config.json, and the plugin host does not forward ANIMUS_TRANSPORT_HTTP_BIND to the plugin. To reach the API from another container, publish it through a forwarder (Section 2).

REST surface — /api/v1 ​

All endpoints return the same JSON envelope:

json
{ "ok": true,  "data": { ... } }
{ "ok": false, "error": { "code": "some_error_code", "message": "..." } }

Authentication ​

Requests through the forwarder (Section 2, port 8088) must carry ANIMUS_WEBAPI_TOKEN as a Bearer token. The transport itself ignores the header, so the examples below also work as-is inside the container against localhost:8080:

bash
curl -H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN" http://localhost:8080/api/v1/subjects

The transport plugin itself has no built-in authentication. In production always front it with an authenticated gateway (see Section 3).

Create a subject ​

bash
curl -s -X POST http://localhost:8080/api/v1/subjects \
  -H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "kind":   "task",
    "title":  "Implement feature X",
    "body":   "Detailed description here",
    "status": "ready",
    "labels": ["team:backend", "priority:high"]
  }'

Response:

json
{
  "ok": true,
  "data": {
    "id":         "task:TASK-001",
    "kind":       "task",
    "title":      "Implement feature X",
    "status":     "ready",
    "labels":     ["team:backend", "priority:high"],
    "body":       "Detailed description here",
    "created_at": "2026-06-17T10:00:00Z"
  }
}

Get a subject by ID ​

bash
curl -s "http://localhost:8080/api/v1/subjects/task%3ATASK-001" \
  -H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN"

URL-encode the : in the subject ID (task:TASK-001 → task%3ATASK-001).

List subjects by kind ​

bash
curl -s "http://localhost:8080/api/v1/subjects?kind=task&limit=50" \
  -H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN"

The response envelope carries a subjects array. The typed list route does not support label filtering; use the plugin-call escape hatch below for that.

Update a subject's labels ​

bash
curl -s -X POST "http://localhost:8080/api/v1/subjects/task%3ATASK-001" \
  -H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"labels_add": ["visibility:public"], "labels_remove": ["visibility:private"]}'

Enqueue a subject for execution ​

Push a subject onto the daemon's dispatch queue. The daemon leases it and runs the project's configured workflow. This is more durable than triggering a workflow directly because it survives the HTTP request lifecycle and daemon restarts.

bash
curl -s -X POST http://localhost:8080/api/v1/queue/enqueue \
  -H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task_id": "task:TASK-001"}'

Plugin-call escape hatch (server-side filtering) ​

bash
curl -s -X POST "http://localhost:8080/api/v1/subject/animus-subject-default/call" \
  -H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "method": "subject/list",
    "params": {
      "kind":       ["task"],
      "labels_any": ["team:backend"]
    }
  }'

Replace animus-subject-default with the installed subject backend plugin name. Use animus plugin list to see installed plugin names.

Check daemon health ​

bash
curl -s "http://localhost:8080/api/v1/daemon/health" \
  -H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN"

Summary of confirmed REST endpoints ​

MethodPathDescription
POST/api/v1/subjectsCreate a subject (kind, title, body, status, labels)
GET/api/v1/subjects/:idGet a subject by ID (URL-encode the : in the id)
GET/api/v1/subjects?kind=<k>&limit=<n>List subjects by kind
POST/api/v1/subjects/:idUpdate subject labels (labels_add, labels_remove)
POST/api/v1/subject/:plugin/callRaw plugin RPC (method + params; use for label filtering)
POST/api/v1/queue/enqueueEnqueue a subject (task_id)
GET/api/v1/daemon/healthDaemon health check

Security model ​

In production:

  1. No public domain on the daemon service. Use private networking so the transport is only reachable from within the deployment project.
  2. An authenticated gateway sits in front, enforces caller auth, and adds Authorization: Bearer $ANIMUS_WEBAPI_TOKEN to every forwarded request.
  3. ANIMUS_WEBAPI_TOKEN is a shared secret between the gateway and the transport-side forwarder. Set it via your platform's secret injection mechanism, not in source control.
  4. The transport answers only requests addressed to this machine (animus-transport-http v0.2.4+). Host must be localhost, a 127.x.x.x address or [::1], and a browser Origin, when sent, must be one of those too; anything else gets 403. This stops web pages in a local browser from driving the API through DNS rebinding. A proxy in front of the transport should send Host: 127.0.0.1:8080; if it can't, list the names it passes on in ANIMUS_TRANSPORT_ALLOWED_HOSTS (comma-separated, in the environment of animus web serve).

5. GraphQL (animus-transport-graphql) ​

Installing and starting ​

bash
# Install the full transport set (HTTP required, GraphQL + web UI recommended):
animus plugin install-defaults --include-transports

# Or individually:
animus plugin install launchapp-dev/animus-transport-http
animus plugin install launchapp-dev/animus-transport-graphql
animus plugin install launchapp-dev/animus-web-ui

Start all installed transport plugins at once:

bash
animus web serve

Each installed transport_backend plugin binds its own port and reports its URL at startup. The GraphQL transport typically binds at http://localhost:4000/graphql by default, but always check the animus web serve startup output for the exact address — it may vary by install configuration.

Introspecting the schema ​

Use any GraphQL client or run:

bash
curl -s -X POST http://localhost:4000/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{ __schema { types { name kind } } }"}'

Or retrieve the SDL via the transport's plugin-call:

bash
curl -s -X POST http://localhost:8080/api/v1/subject/animus-transport-graphql/call \
  -H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"method":"transport/schema","params":{}}'

The GraphQL surface covers the operations the web UI consumes: workflows, subjects (tasks, requirements), queue, daemon control, skills, event subscriptions, and outputs. The full CLI + MCP surface is not covered by GraphQL — use the CLI or MCP tools for operations outside this set.

Example queries ​

Field names below reflect the documented subject model. Verify exact field names against the running schema introspection before using in production.

graphql
query ListTasks {
  subjects(kind: "task", limit: 20) {
    id
    title
    status
    labels
    createdAt
  }
}

query GetWorkflows {
  workflows {
    id
    status
    currentPhase
  }
}

Example subscription ​

The GraphQL transport exposes live event subscriptions. The exact subscription transport (WebSocket or SSE) is version-specific — introspect the schema to confirm:

graphql
subscription WorkflowEvents {
  workflowEvents {
    workflowId
    kind
    phase
    timestamp
  }
}

What is not locally verified ​

The animus-transport-graphql plugin is out-of-tree at launchapp-dev/animus-transport-graphql. The following specifics are not verifiable without a running instance:

  • Exact bound port (check animus web serve output).
  • Exact field names and types (introspect via the running endpoint).
  • Auth model for the GraphQL endpoint (likely the same ANIMUS_WEBAPI_TOKEN bearer pattern; confirm with animus plugin info --name animus-transport-graphql).
  • Subscription transport type (WebSocket vs SSE).

Use animus plugin info --name animus-transport-graphql and schema introspection to ground your client implementation against the installed version.


Quick Reference ​

bash
# Install all transport plugins
animus plugin install-defaults --include-transports

# Verify all required plugin roles are satisfied
animus daemon preflight --project-root .

# Run daemon in foreground (correct for containers)
animus daemon run --project-root . &

# Start transport plugins
animus web serve --project-root .

# Health check via HTTP transport
curl http://localhost:8080/api/v1/daemon/health \
  -H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN"

# Create a subject
curl -X POST http://localhost:8080/api/v1/subjects \
  -H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"kind":"task","title":"My task","status":"ready"}'

# Enqueue a subject for workflow execution
curl -X POST http://localhost:8080/api/v1/queue/enqueue \
  -H "Authorization: Bearer $ANIMUS_WEBAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task_id":"task:TASK-001"}'

# Introspect GraphQL schema
curl -X POST http://localhost:4000/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{ __schema { types { name } } }"}'

Released under the Elastic License 2.0 (ELv2).