podium

module
v0.1.0-rc.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 10, 2026 License: MIT

README

Podium

Run containerised tasks on machines you own, from anywhere, with one command.

MIT licence ci / go ci / web

A control plane (podium-server) schedules work and serves a web UI. A daemon (podium-node) on each worker runs tasks with the local Docker engine and streams their logs back live. A CLI (podium) submits and follows them, and exits with the task's own exit code.

podium run --image alpine:3 -- sh -c 'for i in 1 2 3; do echo tick $i; sleep 1; done; exit 3'
→ task task_01m1j88gv0sg8xaxawdh1fqm0z
→ scheduled on node_01m1j889e944prdn29s2x2d6pa
→ running
tick 1
tick 2
tick 3
→ finished exit 3 in 3.2s

No tagged release yet. There is no binary to download and no image to pull — building from source is the only way in, and the Quickstart below is the whole of it. Read Limitations before putting Podium anywhere that matters.


Why

Three machines under your desk, or three in a rack, or one laptop and two boxes on a shelf. You want to run something on them — a scrape, a build, a browser, a batch job — and you want to watch it happen, get its exit code, and keep what it produced.

Podium is that, and deliberately not much more:

  • Workers dial out. A node opens one stream to the control plane and listens for nothing. No inbound port, no firewall rule, no address for the control plane to know. Adding a worker on another continent is the same operation as adding one on the same desk.
  • A task is one container run, on one machine, to one exit code — with its own private network, a fresh workspace volume, optional sidecars started and waited for, resource limits that are actually enforced, and secrets that are encrypted at rest and shredded afterwards.
  • You see it happen. Logs stream live to the CLI and the browser, byte-exact across a server restart or a node reconnect.
  • It cleans up after itself. Containers, networks, volumes, secret files. A clean run leaves nothing behind.

Why not

Read What a task is not before you start. Podium has no pipelines, no cron, no task retries, no build cache, no interactive exec, and a task cannot span two machines. If you want a pipeline, the shell inside your container is the pipeline.


Quickstart

One file, one command. Save deploy/docker-compose.yml anywhere and run:

docker compose up -d --wait
open http://127.0.0.1:8080          # the token is `podium`

That is the whole procedure. Postgres, an object store, the control plane and the conductor, with the web UI on 8080 and the Agent screen already wired. No Go toolchain, no Node, no binary on the host, no second file, and no .env to write first — the Postgres bootstrap script is inline in the compose file, the conductor's profile directory ships inside its image, and the master key is generated into a volume on first up.

It ships with default credentials, which is what makes that one command possible. Three of the four are only reachable inside the compose network. The fourth, PODIUM_LOCAL_TOKEN, is the only thing between a caller and the whole API — and 8080 is published, on 127.0.0.1 alone, so the exposure is anyone on that machine. For anything you would miss, write a .env beside the compose file before the first up and override them:

printf 'PODIUM_LOCAL_TOKEN=%s\nPODIUM_PG_PASSWORD=%s\n' \
  "$(openssl rand -hex 32)" "$(openssl rand -hex 16)" > .env

PODIUM_PG_PASSWORD is baked into the Postgres volume when it is initialised, so changing it later means down -v or an ALTER ROLE.

The whole file

This is all of it. Copy it into docker-compose.yml and you have the deployment above — docker compose up -d --wait and nothing else.

# Podium, whole, in one file:   docker compose up -d --wait   →   http://127.0.0.1:8080
#
# It ships working defaults so that command needs nothing from you. PODIUM_LOCAL_TOKEN
# ("podium") is the only credential reachable off the compose network, and only on loopback;
# override it and PODIUM_PG_PASSWORD in a .env BEFORE the first `up`, because the Postgres
# password is baked into the volume when it is initialised.
#
#   docker compose --profile node up -d     add a worker on this machine (tasks need one)
#   docker compose run --rm cli nodes       the CLI, without installing it
#
# Set PODIUM_MEMORY_LLM_API_KEY, or the `hindsight` container alone will not start: it wants
# an LLM key of its own for fact extraction. Any of its 25+ providers will do — see
# PODIUM_MEMORY_LLM_PROVIDER below. Nothing else depends on it.
#
# Every variable: .env.example. The walkthrough: ../docs/quickstart.md
name: podium

services:
  # Base images are pinned by digest; a tag is a moving target.
  postgres:
    image: pgvector/pgvector:pg16@sha256:ccc6e83d6e35e931dc7c5def2022729d5a6c370318d099181995567ff1fb4d6b
    environment:
      POSTGRES_USER: podium
      POSTGRES_PASSWORD: ${PODIUM_PG_PASSWORD:-podium}
      POSTGRES_DB: podium
    volumes:
      - pgdata:/var/lib/postgresql/data     # real storage, not a cache. Back it up.
    configs:
      - source: postgres-init               # inline below; runs only on an EMPTY volume
        target: /docker-entrypoint-initdb.d/10-databases.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U podium -d podium"]
      interval: 2s
      timeout: 3s
      retries: 30
      start_period: 5s
    restart: unless-stopped
    # No published port: the server reaches it over the compose network.

  # The agents' shared memory. All state is in Postgres, so this container needs no volume.
  hindsight:
    image: ghcr.io/vectorize-io/hindsight:0.9.2@sha256:84ab276b8f501546deb6ea9c64a57291718b4e16a59dd9e02a02fdd5adfe9028
    depends_on:
      postgres:
        condition: service_healthy
    shm_size: 1g
    environment:
      HINDSIGHT_API_DATABASE_URL: postgresql://podium:${PODIUM_PG_PASSWORD:-podium}@postgres:5432/podium_memory
      # Hindsight has NO authentication until this extension is given a key.
      HINDSIGHT_API_TENANT_EXTENSION: hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
      HINDSIGHT_API_TENANT_API_KEY: ${PODIUM_AGENT_MEMORY_API_KEY:-podium}
      # Any of Hindsight's 25+ providers — LiteLLM sits underneath. openai, gemini, groq,
      # bedrock, vertexai, ollama, lmstudio and the subscription ones all work; change the
      # model to match. https://hindsight.vectorize.io/developer/models
      HINDSIGHT_API_LLM_PROVIDER: ${PODIUM_MEMORY_LLM_PROVIDER:-anthropic}
      HINDSIGHT_API_LLM_MODEL: ${PODIUM_MEMORY_LLM_MODEL:-claude-opus-5}
      HINDSIGHT_API_LLM_API_KEY: ${PODIUM_MEMORY_LLM_API_KEY:-}   # REQUIRED or this exits
      HINDSIGHT_API_EMBEDDINGS_PROVIDER: local                    # bundled, so offline
      HINDSIGHT_ENABLE_CP: "false"                                # Podium's UI is the front door
      HINDSIGHT_API_WORKER_ID: hindsight                          # stable, or retains wedge
    ports:
      # Loopback, because the key above has a default. A worker running agent turns needs it
      # wider — a task container reaches the host by bridge gateway, not loopback — so set
      # PODIUM_MEMORY_BIND and a real key together. See ../docs/security.md.
      - "${PODIUM_MEMORY_BIND:-127.0.0.1}:${PODIUM_MEMORY_PORT:-8888}:8888"
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8888/health >/dev/null || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 30
      start_period: 30s
    restart: unless-stopped

  # Artifacts and rolled-up logs. Nodes never talk to it: an artifact goes node → server →
  # here, which is why no port is published and no worker holds a credential for it.
  objectstore:
    image: rustfs/rustfs:1.0.0-rc.5@sha256:c36b3efea3d1e503f1a2581abd0e7611e0e5820dd30e1850a52384b3fc52bda4
    security_opt:
      - "no-new-privileges:true"
    environment:
      RUSTFS_VOLUMES: /data
      RUSTFS_ADDRESS: 0.0.0.0:9000
      RUSTFS_ACCESS_KEY: ${PODIUM_S3_ACCESS_KEY:-podium}
      RUSTFS_SECRET_KEY: ${PODIUM_S3_SECRET_KEY:-podiumpodium}
      RUSTFS_CONSOLE_ENABLE: "false"      # the stored-XSS advisories were all in the console
      RUSTFS_OBS_LOGGER_LEVEL: warn
    volumes:
      - objectstore-data:/data            # the only copy of a finished task's output. Back it up.
    healthcheck:
      test: ["CMD", "sh", "-ec", "wget -q -O- http://127.0.0.1:9000/health >/dev/null || exit 1"]
      interval: 5s
      timeout: 3s
      retries: 20
      start_period: 5s
    restart: unless-stopped

  # Generates the master key into server-state on first `up`, then exits. Idempotent.
  # busybox, because gen-master-key refuses to overwrite and distroless has no shell to test
  # for the file first; 64 hex characters is the format, 65532 the uid the server runs as.
  init:
    image: busybox:1.37@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0
    volumes:
      # PODIUM_STATE_DIR=./state puts the master key on the HOST instead of inside a volume —
      # a file you can see and back up, and one `down -v` cannot take. Compose reads a path as
      # a bind mount and a bare name as a named volume, so this one variable switches between
      # them. Must match the server's below. See ../docs/operations.md#the-master-key.
      - ${PODIUM_STATE_DIR:-server-state}:/state
    command:
      - sh
      - -ec
      - |
        if [ ! -s /state/master.key ]; then
          umask 077
          head -c 32 /dev/urandom | od -An -tx1 | tr -d '[:space:]' > /state/master.key
          echo "init: generated a master key — back up the server-state volume"
        fi
        chmod 0600 /state/master.key
        chown -R 65532:65532 /state
    restart: "no"

  server:
    image: ${PODIUM_SERVER_IMAGE:-${PODIUM_IMAGE_REPO:-ghcr.io/podium-ade}/podium-server:${PODIUM_IMAGE_TAG:-latest}}
    depends_on:
      postgres:
        condition: service_healthy
      objectstore:
        condition: service_healthy
      init:
        condition: service_completed_successfully
    environment:
      PODIUM_DATABASE_URL: postgres://podium:${PODIUM_PG_PASSWORD:-podium}@postgres:5432/podium
      PODIUM_TRANSPORT: local
      # Binds every interface INSIDE the container — loopback there is the container's own and
      # unreachable. The published port below is the real boundary, hence the waiver. Do not
      # set either on a host, and do not publish 8080 on 0.0.0.0.
      PODIUM_LOCAL_LISTEN: 0.0.0.0:8080
      PODIUM_LOCAL_ALLOW_UNSAFE_LISTEN: "true"
      PODIUM_LOCAL_TOKEN: ${PODIUM_LOCAL_TOKEN:-podium}   # whoever holds it can do everything
      PODIUM_MASTER_KEY_FILE: /var/lib/podium/master.key  # written by `init`; NO recovery path
      PODIUM_S3_ENDPOINT: objectstore:9000
      PODIUM_S3_BUCKET: ${PODIUM_S3_BUCKET:-podium}
      PODIUM_S3_ACCESS_KEY: ${PODIUM_S3_ACCESS_KEY:-podium}
      PODIUM_S3_SECRET_KEY: ${PODIUM_S3_SECRET_KEY:-podiumpodium}
      PODIUM_LOG_ROLLUP_INTERVAL: ${PODIUM_LOG_ROLLUP_INTERVAL:-1m}
      PODIUM_LOG_PRUNE_INTERVAL: ${PODIUM_LOG_PRUNE_INTERVAL:-1h}
      PODIUM_LOG_CHUNK_GRACE: ${PODIUM_LOG_CHUNK_GRACE:-24h}
      PODIUM_AGENT_URL: ${PODIUM_AGENT_URL-http://agent:8090}   # empty = no Agent screen
      PODIUM_AGENT_TOKEN: ${PODIUM_AGENT_TOKEN:-podium}
    volumes:
      # The master key and the tsnet identity, neither recoverable. BACK IT UP:
      #   docker compose cp server:/var/lib/podium/master.key .
      # Or set PODIUM_STATE_DIR=./state and it is ./state/master.key on the host, outside the
      # reach of `down -v`. Same value as `init` above.
      - ${PODIUM_STATE_DIR:-server-state}:/var/lib/podium
    ports:
      - "127.0.0.1:${PODIUM_PORT:-8080}:8080"
    restart: unless-stopped
    # No healthcheck: distroless, so nothing to exec. Probe /readyz instead.

  # The conductor. An ordinary API client of the server: its own database, its own token, no
  # master key, no Docker socket. Its profile directory ships in the image at /etc/podium/agent
  # — mount your own over it, read-only, to run your own bot. See ../docs/agent.md.
  agent:
    image: ${PODIUM_AGENT_IMAGE:-${PODIUM_IMAGE_REPO:-ghcr.io/podium-ade}/podium-agent:${PODIUM_IMAGE_TAG:-latest}}
    depends_on:
      - server
    environment:
      PODIUM_AGENT_SERVER: http://server:8080
      PODIUM_AGENT_API_TOKEN: ${PODIUM_LOCAL_TOKEN:-podium}
      PODIUM_AGENT_DATABASE_URL: postgres://podium:${PODIUM_PG_PASSWORD:-podium}@postgres:5432/podium_agent
      PODIUM_AGENT_LISTEN: 0.0.0.0:8090   # no ports: the server proxies it, one origin one login
      PODIUM_AGENT_TOKEN: ${PODIUM_AGENT_TOKEN:-podium}
      PODIUM_AGENT_PROFILE_DIR: /etc/podium/agent
      PODIUM_AGENT_SLACK_APP_TOKEN: ${PODIUM_AGENT_SLACK_APP_TOKEN:-}   # both or neither
      PODIUM_AGENT_SLACK_BOT_TOKEN: ${PODIUM_AGENT_SLACK_BOT_TOKEN:-}
      PODIUM_AGENT_LINEAR_API_KEY: ${PODIUM_AGENT_LINEAR_API_KEY:-}
      PODIUM_AGENT_LINEAR_POLL_INTERVAL: ${PODIUM_AGENT_LINEAR_POLL_INTERVAL:-30s}
      PODIUM_AGENT_LINEAR_URL: ${PODIUM_AGENT_LINEAR_URL:-https://api.linear.app/graphql}
      PODIUM_AGENT_UI_URL: ${PODIUM_AGENT_UI_URL:-}   # how a human reaches the UI, for links
      PODIUM_AGENT_MEMORY_URL: ${PODIUM_AGENT_MEMORY_URL-http://hindsight:8888}
      PODIUM_AGENT_MEMORY_TASK_URL: ${PODIUM_AGENT_MEMORY_TASK_URL:-http://host.docker.internal:8888}
      PODIUM_AGENT_MEMORY_BANK: ${PODIUM_AGENT_MEMORY_BANK:-podium}
      PODIUM_AGENT_MEMORY_API_KEY: ${PODIUM_AGENT_MEMORY_API_KEY:-podium}
      PODIUM_AGENT_XAI_BASE_URL: ${PODIUM_AGENT_XAI_BASE_URL:-https://api.x.ai}
      PODIUM_AGENT_XAI_OAUTH_ISSUER: ${PODIUM_AGENT_XAI_OAUTH_ISSUER:-https://auth.x.ai}
      PODIUM_AGENT_XAI_OAUTH_CLIENT_ID: ${PODIUM_AGENT_XAI_OAUTH_CLIENT_ID:-}
    restart: unless-stopped
    # A turn needs an Anthropic key, set in the UI so it lands in the encrypted secret store.

  # A worker on THIS machine. Behind a profile because it is a decision: it mounts the Docker
  # socket. A worker elsewhere cannot use this file — the local transport is loopback-only;
  # use docker-compose.tailnet.yml. See ../docs/networking.md.
  node:
    profiles: ["node"]
    image: ${PODIUM_NODE_IMAGE:-${PODIUM_IMAGE_REPO:-ghcr.io/podium-ade}/podium-node:${PODIUM_IMAGE_TAG:-latest}}
    depends_on:
      - server
    environment:
      PODIUM_NODE_SERVER: http://server:8080
      PODIUM_NODE_TRANSPORT: local
      PODIUM_NODE_LOCAL_TOKEN: ${PODIUM_LOCAL_TOKEN:-podium}
      PODIUM_NODE_ENROLL_TOKEN: ${PODIUM_NODE_ENROLL_TOKEN:-}   # single use, first run only
      PODIUM_NODE_DATA_DIR: /var/lib/podium-node
      PODIUM_NODE_MAX_TASKS: ${PODIUM_NODE_MAX_TASKS:-4}
      PODIUM_NODE_LABELS: ${PODIUM_NODE_LABELS:-}
    volumes:
      # A HOST PATH at the same absolute path inside and out, and it has to be: this node
      # drives the HOST's daemon, so the runner it puts into each task container as PID 1 must
      # sit where that daemon can resolve it. From a named volume every task dies at creation.
      # It therefore survives `down -v` — see ../docs/quickstart.md#tearing-it-down.
      - /var/lib/podium-node:/var/lib/podium-node
      - /var/run/docker.sock:/var/run/docker.sock   # ROOT-EQUIVALENT ON THIS HOST
    restart: unless-stopped

  # The CLI as a one-shot, so nothing has to be installed. `docker compose run` turns the
  # profile on by itself. A --spec is read here, so mount it: -v "$PWD/specs:/specs:ro"
  cli:
    profiles: ["cli"]
    image: ${PODIUM_CLI_IMAGE:-${PODIUM_IMAGE_REPO:-ghcr.io/podium-ade}/podium:${PODIUM_IMAGE_TAG:-latest}}
    depends_on:
      - server
    environment:
      PODIUM_SERVER: http://server:8080   # the compose network's name, not the published port
      PODIUM_LOCAL_TOKEN: ${PODIUM_LOCAL_TOKEN:-podium}
    restart: "no"

volumes:
  pgdata:
  objectstore-data:
  server-state:

# Inline, so this deployment is one file. Byte-identical to postgres/init.sql, which the dev
# and tailnet compose files mount from disk; `go test ./deploy/...` fails if they drift.
configs:
  postgres-init:
    content: |
      -- The databases beside podium's own. Postgres runs this ONLY on an empty data directory;
      -- to add them to an existing install, see docs/operations.md.
      create database podium_agent owner podium;   -- the conductor's, migrated by podium-agent
      create database podium_memory owner podium;  -- Hindsight's, migrated by Hindsight

      \connect podium_memory

      -- Hindsight needs pgvector in `public` and would otherwise DROP EXTENSION ... CASCADE and
      -- recreate it there itself. podium and podium_agent stay extension-free.
      create extension if not exists vector;

A worker. Tasks need one, and it is the one thing that stays opt-in: it mounts the host's Docker socket, which is root-equivalent on that host — read docs/security.md before putting one anywhere real. Without it a submitted task sits in queued and says why.

echo "PODIUM_NODE_ENROLL_TOKEN=$(docker compose run --rm cli \
  node enroll-token --label demo)" >> .env
docker compose --profile node up -d

The enrollment token is the one value that cannot be written ahead of time: only a running control plane can mint one, and it is single-use — a worker that has enrolled has identity.json and never reads it again. For a worker on another machine the local transport is the wrong tool, being loopback-only; see Running across machines.

Run something. The cli profile is the CLI as a one-shot, so there is no binary to install; docker compose run turns the profile on by itself.

docker compose run --rm cli run --image alpine:3 -- echo hello

The CLI exits with the task's exit code, which is what makes it usable as a CI step. Podium's own commentary goes to stderr with a , so redirecting stdout captures exactly the task's.

A task can bring its own environment with it. A --spec is read by the CLI, so mount it where the container can see it:

docker compose run --rm -v "$PWD/specs:/specs:ro" cli run --spec /specs/postgres-sidecar.yaml
→ task task_01m1jsfzne7c8v1p1n4h4rjkq3
→ sidecar/db started
[db] LOG:  database system is ready to accept connections
→ sidecar/db ready
→ running
 ?column?
----------
        1
(1 row)
→ finished exit 0 in 200ms

A sidecar is a sibling container on the task's private network, addressed by name — psql -h db — started before the task and waited for. More in examples/: hello.yaml, postgres-sidecar.yaml, secrets.yaml, limits.yaml, artifacts.yaml.

The master key. init generates it on the first up, every stored secret is encrypted under it, and there is no recovery path. By default it lives in the server-state volume, so copying it out is a command you have to remember — and down -v destroys it:

docker compose cp server:/var/lib/podium/master.key ./master.key

Simpler: keep it on the host from the start. Set this before the first up and the key is an ordinary file at ./state/master.key, mode 0600, that down -v cannot touch:

echo 'PODIUM_STATE_DIR=./state' >> .env

A bare name there is a Docker volume, a path is a bind mount, and compose tells them apart by the leading dot or slash. Either way, get a copy somewhere that is not this machine.

Full walkthrough, including tearing it down: docs/quickstart.md.

The ghcr.io/podium-ade/* tags do not exist until a v* tag is pushed. Until then, build the four images and set PODIUM_IMAGE_REPO to a registry you can reach — the recipe is in docs/quickstart.md. To work on Podium rather than run it, CONTRIBUTING.md has the source-built stack.

The agent layer

The conductor comes up with everything else. It is an ordinary API client of podium-server — its own database, its own token, and it never touches Docker — and it turns a Slack mention, a Linear assignment or a web-chat message into one turn. The UI's Agent screen is its front end, reverse-proxied behind the server's identity middleware so there is one origin and one login. The playbook a turn runs is the worked example baked into the agent image at /etc/podium/agent; mount your own profile directory over it to replace it.

Two things it does not have out of the box, and both are credentials:

  • A model key. A turn needs an Anthropic key, and it is set in the UI rather than in .env on purpose — that way it lands in the encrypted secret store instead of in docker inspect output.

  • Shared memory. Hindsight comes up with everything else, but it wants an LLM key of its own for fact extraction and exits at boot without one. It is the only container that does, so up brings the rest of the stack up regardless — and one line turns it on:

    echo 'PODIUM_MEMORY_LLM_API_KEY=...' >> .env
    docker compose up -d
    

    It does not have to be Anthropic. Hindsight runs on any of ~25 providers with LiteLLM underneath — OpenAI, Gemini, Groq, Bedrock, Vertex AI, or a local Ollama or LM Studio, which keeps memory extraction off the network entirely. Set PODIUM_MEMORY_LLM_PROVIDER and PODIUM_MEMORY_LLM_MODEL to match the key; the defaults are anthropic and claude-opus-5.

    Turning it on has a networking consequence worth reading before you do: the memory port is published on loopback by default, but an agent turn runs in a task container that reaches the host through the bridge gateway, which loopback is not reachable from. Widening it means setting PODIUM_MEMORY_BIND — and a real PODIUM_AGENT_MEMORY_API_KEY at the same time, because Hindsight has no authentication beyond that key. See docs/agent.md and docs/security.md.

The binaries

Each of the first four is also a published image — ghcr.io/podium-ade/podium-server, -node, -agent, and ghcr.io/podium-ade/podium for the CLI — and that is the way in. They are single static Go binaries on a distroless base, so an image is the binary and a certificate bundle and nothing else. The release archive has the same binaries loose, for a host that would rather run them directly.

podium-server API, scheduler, node registry, secrets, log ingest, embedded web UI. Needs Postgres (pgvector/pgvector:pg16); optionally an S3-compatible store
podium-node One per worker. Runs tasks on the local Docker engine. Root-equivalent on its host — read security.md
podium-agent The conductor. Turns a Slack mention, a Linear assignment or a web-chat message into one turn — a conversation answered on its own host, or a task running an agent runtime image — and relays the answer back. An ordinary API client of podium-server: its own database, its own token, never touches Docker. See docs/agent.md
podium The CLI. Talks only to the server, never to Docker, so it runs anywhere
podium-runner PID 1 inside every task container: runs the command, forwards signals, reaps orphans, reports events. Embedded in podium-node and bind-mounted in; never installed by hand

Transports

PODIUM_TRANSPORT decides how clients and workers reach the control plane. The two supported values differ on one thing — who names the caller — and everything else follows from it.

local tailnet
The wire HTTP on loopback HTTPS on the server's MagicDNS name
Who the caller is nobody. One shared bearer and no identity behind it a Tailscale identity, from WhoIs
What you present PODIUM_LOCAL_TOKEN — from the CLI, the browser and every node nothing. There is no token to hold
Where a worker can be the same machine anywhere on your tailnet
Set up the Quickstart Running across machines, below

The local transport refuses to bind anywhere but loopback, because that one token is the only thing between a caller and the whole API. It is for one machine you are sitting at, and it is what the Quickstart runs. Anything else is tailnet, including a second machine on the same desk — see below.

There is a third value, host, which serves the same HTTPS over the machine's existing tailscaled rather than an embedded device. It has never been run.

Running across machines

The tailnet transport is the only supported way to reach a worker on another machine — in development as much as in production, and not merely the recommended one. Podium joins your Tailscale network: the server serves HTTPS on its MagicDNS name, workers dial out, and there is no login page, no API token and no public ingress.

docker run --rm -v "$PWD:/out" --user "$(id -u):$(id -g)" \
  ghcr.io/podium-ade/podium-server:latest \
  init --dir /out --transport tailnet --tailnet <magicdns-suffix>
$EDITOR .env                            # paste TS_AUTHKEY; init reports what else is missing
docker compose -f docker-compose.tailnet.yml up -d --wait

# from any device on the tailnet — no token, no login
podium --server https://podium.<tailnet>.ts.net nodes

That last line is a CLI on your own machine, not in a container: the tailnet compose file has no cli profile, because the server listens on port 443 of its own Tailscale device and has no address on the compose network for a sibling container to reach.

Read docs/networking.md first: what to create in the Tailscale admin console, the ACL, and the two different keys involved (a Tailscale auth key and a Podium enrollment token are not the same thing). Then docs/node-setup.md for the worker at the other end.

What happens when things go wrong

The control plane places work on the node with the most free slots that carries every label the task asks for and has room for its CPU and memory — its sidecars' included. A task it cannot place stays queued and says why.

After that it keeps the promises placement made:

  • a node that takes an assignment and does not acknowledge it within 15 seconds loses it;
  • a task that outruns its timeout is stopped and ends failed{reason: timeout};
  • a task that exceeds resources.memory_mb is OOM-killed and reported as such, not swapped;
  • a node that stops heartbeating is unreachable at 30 seconds and offline at 120, at which point its tasks are requeued (retry_on_node_loss: true) or marked lost — which is not failed: nothing about the task went wrong, its machine went away;
  • a node that comes back is told what the control plane actually holds for each container it still has, so its logs resume at the right byte, and any container the control plane has written off is torn down instead of being left running.
podium node drain worker-3      # finishes what it has, takes nothing new
podium node undrain worker-3
podium node slots worker-3 2    # or just turn it down: 2 tasks at once, 0 to undo
podium node rm worker-3         # once it is drained and idle

A node started with --exit-on-drain exits 0 when its last task finishes, which is the upgrade path. A slot count is the softer version of a drain: like draining it is stored against the node and survives both daemons restarting, and it goes up as well as down — the number is sent to the node, because a node enforces its own budget and rejects work it has no slot for.


Documentation

Start here

Using it

  • docs/task-spec.md — every spec field: secrets, sidecars, readiness, limits, hardening, artifacts
  • docs/cli.md — every command, its exit codes and its streams (contractual)
  • examples/ — hello, sidecar, secrets, limits, artifacts

Running it

Internals


Configuration

Every daemon is configured entirely by environment, and one file is the whole of it: a .env beside the compose file. The compose file interpolates it, and set -a; . .env; set +a configures a host CLI from the same lines. Nothing here asks you to declare a variable anywhere else. deploy/.env.example documents every variable there is with its default, and go test ./deploy/... fails the build if the code reads one that file does not mention, or if that file documents one nothing reads any more.

Nothing is required to start. Every variable the compose file interpolates has a working default, and a test enforces it: the file may not use a :? interpolation, because one such variable turns docker compose up into an error message. What you set beyond that falls into three tiers — deploy/README.md has the full version with consequences:

tier
Unlocks a feature one variable each, and without it only that feature is off PODIUM_MEMORY_LLM_API_KEY (shared memory), PODIUM_NODE_ENROLL_TOKEN (a worker's first run), PODIUM_AGENT_SLACK_* / PODIUM_AGENT_LINEAR_API_KEY (those sources), TS_AUTHKEY + PODIUM_TAILNET (the tailnet transport)
Credentials with defaults replace before anything you would miss. PODIUM_PG_PASSWORD must be set before the first up PODIUM_LOCAL_TOKEN (the only one that leaves the compose network), PODIUM_AGENT_MEMORY_API_KEY, PODIUM_PG_PASSWORD, PODIUM_S3_SECRET_KEY, PODIUM_AGENT_TOKEN
Just config ports, intervals, models, poll rates, labels, base URLs — all defaulted PODIUM_IMAGE_TAG is the one to pin regardless: latest moves, and a control plane and worker from different releases can disagree about the wire

Running the binaries by hand is the one case with genuinely required variables — seven, which the compose file supplies and make stack-up mostly derives. podium-server init writes the credentials with fresh random values and a master key beside them.

podium-server has three subcommands: init (writes a master key and a filled .env), gen-master-key, and rotate-master-key. /healthz, /readyz and /metrics are open on both daemons; every RPC is behind the transport's identity check.

Development

make build              # UI + runner-embed + all four binaries into bin/, host-native
make test               # unit tests
make test-integration   # + real Postgres and Docker via testcontainers
make e2e                # boots a full stack and drives the real CLI
make lint proto fmt
go build -tags noui ./...   # skip the embedded UI, no Node required

Stop any running podium-node before make test-integration or make e2e. Both suites start real nodes against the host's Docker engine, and a node claims containers by the podium.task label alone — no node scoping. Each side reports the other's containers to its own control plane, which has never heard of them, and tears them down. You lose the test run and whatever the live node was running, and it looks like flakiness or memory pressure. It is not. (DOCKER_HOST or PODIUM_NODE_DOCKER_HOST pointed at a second engine separates them too, if you have one.)

podium-runner is the one binary that is never host-native: it is PID 1 inside a Linux task container, so make build cross-compiles it for linux/amd64 and linux/arm64 into internal/node/docker/runnerbin/ (embedded into podium-node, gitignored, never committed) and copies the host architecture's build to bin/podium-runner. A clone that has never run make runner-embed still compiles — go:embed finds a committed placeholder — but a node started from it refuses to run tasks and says which target to build.

See CONTRIBUTING.md.


Limitations

What Podium does not do, and what will surprise you if nobody says it first.

Architectural, and not going to change soon

  • Single server process. Node sessions are held in memory, so only the server holding a node's stream can assign to it, cancel on it or drain it — and a second replica's health watchdog would see every node as sessionless and start expiring leases. A leader lock is needed before a second replica is ever started.
  • One podium-node per Docker engine. At startup the daemon claims every container on the engine labelled podium.task, whichever daemon created it, and tears down the ones its own control plane does not recognise. So two daemons on one engine destroy each other's work. Nothing enforces it. It bites hardest in development — see Development.
  • No RBAC. The tailnet transport records who is visiting in a users table and lets every one of them do everything: submit tasks (and therefore run code as root on every worker), drain nodes, delete secrets. The web UI is the same. The bot widens this a long way: anyone who can mention it in a Slack channel it has joined, or assign it a Linear issue, can make it run code on a worker with that playbook's credentials. A playbook's secrets: list scopes what one bot hands one turn — keep it minimal — but it is not a boundary around the secret store: CreateTask checks only that a named secret exists, so anyone who can reach the API can already mount any registered secret into an image of their own. The agent layer does not change this.
  • No egress policy. A task reaches its sidecars and the internet. Whether it can also reach its worker's other networks depends on the host's routing, and Docker's default forwards it — assume it can, and firewall the host if that matters.

Things that will bite you in normal use

  • A task adopted after a node restart loses three things: its log redactor, its runner event socket, and any sidecar log stream. The container keeps running and its stdout/stderr keep flowing, byte-exact. Auto-collected artifacts are lost. The seam is marked in the task's own history as step{name: "node/reattached"}.
  • Log redaction is best-effort string matching, not a guarantee. It does not catch a value the task transformed, split, or shorter than 8 bytes. It is defence against an accidental echo $PASSWORD, not the control that keeps a secret out of a log.
  • A sidecar cannot reference a secret. A database sidecar that needs a password takes it from a plaintext env: entry.
  • Under the local transport, resolved secret values cross an unencrypted loopback socket. Loopback is doing all the work; the server refuses to bind anywhere else.
  • Nothing is ever deleted except rolled-up log chunks. Tasks, events, artifacts and audit rows grow without bound, and the object store has no lifecycle policy. There is no retention policy and no way to configure one.
  • Rolled-up logs lose the interleaving between streams. Once a finished task's chunks have been pruned, its log replays as stdout then stderr — one object per stream, and nothing records how they were braided together. Within a stream the order is exact.
  • A CPU limit is not visible inside the container. nproc reports the host's cores whatever resources.cpu says, because a CPU quota is not namespaced.
  • Image cache pruning is off by default and only ever removes images Podium pulled itself (data_dir/images.json is the allow-list). A long-lived node accumulates images until someone intervenes; that is the intended trade, because a node shares its engine with the rest of the machine.
  • A moving tag is never refreshed. An image is pulled only when the engine says it is absent, so alpine:3 stays whatever version that node first cached.
  • The control plane emits no Podium metrics at all. /metrics carries the Go and process collectors and nothing else. The node emits three gauges. There is no Grafana dashboard, because there would be nothing honest to put on it.
  • podium node rm does not stop the daemon. A removed node whose identity.json survives reconnects for ever and is told its key is unknown, once per backoff.
  • The web UI holds a task's whole log in memory, cannot jump to an arbitrary page of the task list, shows no node CPU/memory utilisation, and keeps the bearer token in localStorage.

Security

Read docs/security.md before deciding which machines run a node. The short version: a podium-node is root-equivalent on its host, and a task container is untrusted. Report a vulnerability privately — see SECURITY.md.

License

MIT.

Directories

Path Synopsis
cmd
podium command
Command podium is the Podium CLI: it submits tasks, follows them, and inspects the control plane.
Command podium is the Podium CLI: it submits tasks, follows them, and inspects the control plane.
podium-agent command
Command podium-agent is the Podium conductor: it holds the bot's identity in Slack, turns a mention into one Podium task running the agent runtime image, relays what the agent says back into the conversation and records the turn.
Command podium-agent is the Podium conductor: it holds the bot's identity in Slack, turns a mention into one Podium task running the agent runtime image, relays what the agent says back into the conversation and records the turn.
podium-node command
Command podium-node is the Podium worker daemon: it enrolls with the control plane, holds one bidirectional stream to it, and runs assigned tasks as containers on the local Docker engine.
Command podium-node is the Podium worker daemon: it enrolls with the control plane, holds one bidirectional stream to it, and runs assigned tasks as containers on the local Docker engine.
podium-runner command
Command podium-runner is PID 1 inside a Podium task container.
Command podium-runner is PID 1 inside a Podium task container.
podium-server command
Command podium-server is the Podium control plane: the Connect API, the node streams and the scheduler.
Command podium-server is the Podium control plane: the Connect API, the node streams and the scheduler.
internal
agent
Package agent wires the conductor together: its own store, a Podium API client, the profile directory, the sources, and one HTTP server for the AgentService, health and metrics.
Package agent wires the conductor together: its own store, a Podium API client, the profile directory, the sources, and one HTTP server for the AgentService, health and metrics.
agent/api
Package api is the conductor's own HTTP surface: the AgentService handlers, the bearer that guards them, and the health and metrics endpoints.
Package api is the conductor's own HTTP surface: the AgentService handlers, the bearer that guards them, and the health and metrics endpoints.
agent/chat
Package chat is the web chat: the third conductor.Source, and the only one whose conversation Podium itself holds.
Package chat is the web chat: the third conductor.Source, and the only one whose conversation Podium itself holds.
agent/conductor
Package conductor is the turn loop: an inbound message becomes one Podium task running the agent runtime image, what the task says is relayed back to where the message came from, and the turn is recorded.
Package conductor is the turn loop: an inbound message becomes one Podium task running the agent runtime image, what the task says is relayed back to where the message came from, and the turn is recorded.
agent/conductor/fakesource
Package fakesource is an in-memory conductor.Source: a channel of inbound events and an ordered record of everything the conductor said back.
Package fakesource is an in-memory conductor.Source: a channel of inbound events and an ordered record of everything the conductor said back.
agent/config
Package config is the whole of podium-agent's configuration.
Package config is the whole of podium-agent's configuration.
agent/linear
Package linear is the bot's Linear identity: assigned issues polled into inbound events, and the five ways the conductor talks back.
Package linear is the bot's Linear identity: assigned issues polled into inbound events, and the five ways the conductor talks back.
agent/mcp
Package mcp is the conductor's registry of MCP servers: what one is, what a name may be, and which Podium secret and environment variable a server's credential travels in.
Package mcp is the conductor's registry of MCP servers: what one is, what a name may be, and which Podium secret and environment variable a server's credential travels in.
agent/memory
Package memory is the conductor's client of Hindsight, the agents' shared memory.
Package memory is the conductor's client of Hindsight, the agents' shared memory.
agent/podium
Package podium is the conductor's client of the Podium API.
Package podium is the conductor's client of the Podium API.
agent/profiles
Package profiles is the bot's identity and its playbooks: what a playbook is, how one is validated, and how the directory on disk (PODIUM_AGENT_PROFILE_DIR) merges with the playbooks an operator created in the web UI.
Package profiles is the bot's identity and its playbooks: what a playbook is, how one is validated, and how the directory on disk (PODIUM_AGENT_PROFILE_DIR) merges with the playbooks an operator created in the web UI.
agent/skills
Package skills is a third-party Agent Skill on its way into a task container: what a bundle is, how one is read off the conductor's own disk, and the guards it has to pass before any of it reaches a turn.
Package skills is a third-party Agent Skill on its way into a task container: what a bundle is, how one is read off the conductor's own disk, and the guards it has to pass before any of it reaches a turn.
agent/slack
Package slack holds the bot's Slack identity: a Socket Mode connection, mentions turned into inbound events, and the four ways the conductor talks back.
Package slack holds the bot's Slack identity: a Socket Mode connection, mentions turned into inbound events, and the four ways the conductor talks back.
agent/store
Package store is the conductor's own Postgres database (podium_agent): sessions, turns, the relay ledger and its settings.
Package store is the conductor's own Postgres database (podium_agent): sessions, turns, the relay ledger and its settings.
cli
Package cli is the podium command-line client.
Package cli is the podium command-line client.
ids
Package ids mints the lowercase, prefixed ULIDs Podium uses for every entity.
Package ids mints the lowercase, prefixed ULIDs Podium uses for every entity.
node
Package node is the podium-node daemon: it holds the node's identity, keeps one bidirectional stream to the control plane, and turns each Assign into a container run whose events are batched, buffered and replayed until the server acks them.
Package node is the podium-node daemon: it holds the node's identity, keeps one bidirectional stream to the control plane, and turns each Assign into a container run whose events are batched, buffered and replayed until the server acks them.
node/docker
Package docker runs Podium tasks as containers on a local Docker engine and streams ordered lifecycle events for them.
Package docker runs Podium tasks as containers on a local Docker engine and streams ordered lifecycle events for them.
proto/podium/v1
Package podiumv1 holds the generated podium.v1 wire types plus the hand-written log redaction helper below.
Package podiumv1 holds the generated podium.v1 wire types plus the hand-written log redaction helper below.
runner
Package runner is podium-runner: PID 1 of every Podium task container.
Package runner is podium-runner: PID 1 of every Podium task container.
server
Package server wires the control plane together: store, transport, Connect handlers, the scheduler and the log fan-out, behind one HTTP server.
Package server wires the control plane together: store, transport, Connect handlers, the scheduler and the log fan-out, behind one HTTP server.
server/api
Package api holds the operator-facing Connect handlers: tasks and node administration.
Package api holds the operator-facing Connect handlers: tasks and node administration.
server/artifacts
Package artifacts is the control plane's half of the object store: the S3 client, the key layout, presigned URLs, and the service that records what has been stored.
Package artifacts is the control plane's half of the object store: the S3 client, the key layout, presigned URLs, and the service that records what has been stored.
server/artifacts/fakes3
Package fakes3 is an in-process S3-compatible endpoint for Podium's own tests.
Package fakes3 is an in-process S3-compatible endpoint for Podium's own tests.
server/logs
Package logs owns the write side of a task's event history: it turns a node's event batch into rows, applies the status transitions those events imply, and fans the result out to live subscribers.
Package logs owns the write side of a task's event history: it turns a node's event batch into rows, applies the status transitions those events imply, and fans the result out to live subscribers.
server/nodes
Package nodes owns node identity and the node stream: enrollment, the in-memory session registry, heartbeat bookkeeping and the event batches nodes push back.
Package nodes owns node identity and the node stream: enrollment, the in-memory session registry, heartbeat bookkeeping and the event batches nodes push back.
server/scheduler
Package scheduler decides which node runs which task, and keeps the promises that decision makes: a lease per assignment, a deadline for accepting one, the spec's own timeout, and the node-health policy that turns a machine going away into either a new attempt or an honest `lost`.
Package scheduler decides which node runs which task, and keeps the promises that decision makes: a lease per assignment, a deadline for accepting one, the spec's own timeout, and the node-health policy that turns a machine going away into either a new attempt or an honest `lost`.
server/secrets
Package secrets is the control plane's encrypted secret store: the master key, the AES-256-GCM envelope around every stored value, and the resolution step that turns a task's SecretRefs into the plaintext an Assign carries to a node.
Package secrets is the control plane's encrypted secret store: the master key, the AES-256-GCM envelope around every stored value, and the resolution step that turns a task's SecretRefs into the plaintext an Assign carries to a node.
server/store
Package store is the only place SQL lives.
Package store is the only place SQL lives.
transport
Package transport is the seam between how a request reaches the server and who the server thinks made it.
Package transport is the seam between how a request reaches the server and who the server thinks made it.
transport/local
Package local is the single-machine transport: no Tailscale, no mTLS, one shared bearer token.
Package local is the single-machine transport: no Tailscale, no mTLS, one shared bearer token.
transport/tailnet
Package tailnet is the Tailscale transport.
Package tailnet is the Tailscale transport.
version
Package version carries the build identity of every Podium binary.
Package version carries the build identity of every Podium binary.
pkg
spec
Package spec defines the public task specification: the YAML/JSON document an operator writes and the validated Go type every Podium component codes against.
Package spec defines the public task specification: the YAML/JSON document an operator writes and the validated Go type every Podium component codes against.
Package web carries the built single-page UI into the server binary.
Package web carries the built single-page UI into the server binary.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL