Skip to content

service authoring

Mike Crowe edited this page Jul 27, 2026 · 2 revisions

Authoring shared services

A shared service is a heavy or stateful sidecar (hindsight = postgres+MCP, openbrain, a custom MCP tool) that runs as its own image/container/volume on the shared network, with a lifecycle independent of any instance. Multiple instances attach to one running service concurrently; claude+hindsight and omp+hindsight read and write one memory (design §3, §9).

For the why (why services are service-scoped, why they outlive instances, why they're separate images), read docs/harnessed-design.md §3 & §9. This guide shows the how with a worked example from catalog/services/ping/.

What a service is

A service lives at catalog/services/<name>/ and ships three things:

File Role
catalog/services/<name>/service.yaml the manifest: name, image, port, volume, healthcheck
catalog/services/<name>/Dockerfile the service's own image lineage (independent of the harness images)
the server itself (e.g. server.py) the actual MCP server (Streamable HTTP)

You manage services by name with harnessed svc up|down|list (implemented in src/harnessed/launcher.py), and a stack references one by listing it under services:.

The service.yaml manifest

The typed model lives in src/harnessed/schema.py (ServiceDef). Flat scalars:

Only name and image are required; a service that talks over a unix socket has no port at all.

name: <service>                       # required
image: <name>:<tag>                   # required — the service's own image
scope: global | project               # global (default): ONE shared container. project: one per project
port: <port>                          # the CONTAINER port the server listens on
publish: ephemeral | stable           # how `port` reaches the host (loopback-bound; see below)
socket: <path>                        # unix socket relative to the data dir; mutually exclusive with publish
client_env: {VAR: "<template>"}       # env a CLIENT needs to reach this service
data: {persist: <dir>}                # scope: project — where the data dir comes from
volume: <volume-name>                 # service-scoped named volume (default <name>-data, at /data)
healthcheck: "<cmd>"                  # readiness probe for `svc up` to poll
exclusive_lock: <exe>                 # basename of a process that flocks the data dir
sync: "<cmd>"                         # run inside the container by `harnessed svc sync <name>`

Reaching the service: publish, socket, and client_env

port is the port inside the container. How it becomes reachable is a separate choice, and both forms bind loopback only — so a service using either must authenticate (see {password} below):

  • publish: ephemeral — the runtime allocates the host port and the launcher reads it back with podman port on every launch. Nothing is recorded, so nothing goes stale; but nothing outside a harnessed launch can ever be configured with it either.
  • publish: stable — harnessed allocates one free loopback port per project once and records it in $XDG_DATA_HOME/harnessed/svc-ports.json. The registry is machine-wide, so allocations across projects cannot collide, and the value survives restarts, reboots and container recreates. That is what lets the project hold its own client config (e.g. a gitignored mise.local.toml) instead of that config existing only inside a harnessed process.
  • socket: run/<name>.sock — no host port at all; peers reach the service through a socket riding the shared bind-mount. Requires scope: project, and is mutually exclusive with publish. Note that a client speaking TCP for any part of its work cannot use this form.

client_env declares the environment a client needs, and lives here because only the service knows its own protocol's variable names. Values are templated on {host}, {port}, {socket} and {password}; the launcher resolves them per launch and injects them into the agent's environment. Use it rather than a recipe env: for anything that does not exist until the container runs — a recipe env value is resolved at emit time. {host} resolves per mode: 127.0.0.1 for a host agent, host.containers.internal for a containerized one.

exclusive_lock names the executable that takes an exclusive on-disk lock on the data dir (e.g. dolt), turning "nothing else may open this data dir" from a documented hope into an enforced precondition — a host process holding that lock otherwise leaves the sidecar dead on arrival.

catalog/services/beads-server/service.yaml exercises all of the above and documents why each choice was made.

A recipe references a service via mcp.servers[].service: <name>; the assembler resolves that to a hatago URL-proxy entry pointing at http://<name>:<port>/mcp (see Attaching from a recipe below).

Worked example: the ping service

ping is the smallest shared-service sidecar — one ping MCP tool over Streamable HTTP, no external state. All three files:

catalog/services/ping/service.yaml

name: ping
image: harnessed-ping:latest
volume: ping-data
port: 8080
healthcheck: "curl -sf http://localhost:8080/health || exit 1"
  • image: harnessed-ping:latestsvc up builds this from the service's own Dockerfile on first use (and the build-time BLD-02 image scan gates it).
  • volume: ping-data — service-scoped; it survives svc down by default (that's the value — one memory across instances). --purge is the explicit destroy.
  • healthcheck — what svc up polls to confirm readiness before returning.

catalog/services/ping/Dockerfile

FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir "mcp[cli]"
WORKDIR /app
COPY server.py /app/server.py
EXPOSE 8080
HEALTHCHECK --interval=5s --timeout=3s --start-period=3s --retries=3 \
    CMD curl -sf http://localhost:8080/health || exit 1
CMD ["python", "/app/server.py"]

The service has its own image lineage (FROM python:3.12-slim) — it is not built FROM any harness image. The HEALTHCHECK mirrors the manifest's healthcheck so podman and svc up agree on readiness.

catalog/services/ping/server.py

A FastMCP server over Streamable HTTP, with a /health route alongside the MCP endpoint:

from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from starlette.responses import PlainTextResponse
from starlette.routing import Route

mcp = FastMCP("ping")
# FastMCP's DNS-rebinding protection rejects the podman host-gateway Host header by default,
# so allow it alongside the localhost defaults (the service is proxied via host.containers.internal).
mcp.settings.transport_security = TransportSecuritySettings(
    enable_dns_rebinding_protection=True,
    allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*", "host.containers.internal:*"],
)

@mcp.tool()
def ping() -> str:
    """Return pong."""
    return "pong"

async def _health(_request):
    return PlainTextResponse("ok")

# FastMCP.streamable_http_app() serves the MCP endpoint at /mcp; add /health on the same port.
app = mcp.streamable_http_app()
app.router.routes.insert(0, Route("/health", _health, methods=["GET"]))

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8080)
  • Streamable HTTP — one endpoint (/mcp), POST + optional GET/SSE stream. SSE is deprecated in the current MCP spec (2025-06-18) and in Claude Code; do not author new SSE servers (see the "What NOT to Use" table in CLAUDE.md).
  • A separate /health route lets the container HEALTHCHECK (and svc up) probe readiness without speaking MCP.
  • The allowed_hosts entry for host.containers.internal matters on the default rootless networking model (see below).

Lifecycle

harnessed svc up|down|list manages shared services by name — independent of any instance (implemented in src/harnessed/launcher.py):

harnessed svc up ping        # build image (first use) + create volume + run -d + wait for healthcheck
harnessed svc list           # enumerate running harnessed-managed services
harnessed svc down ping      # stop + remove the container (volume KEPT)
harnessed svc down ping --purge   # stop + remove the container AND the volume

The service is labelled harnessed-service=<name> and runs on the shared network; it is not a pod member. A stack that declares the service auto-starts it on launch:

# catalog/stacks/ping-time/stack.yaml
name: ping-time
config: isolated
harness: claude
recipes: [time, ping]
services: [ping]            # ← the isolated launcher runs ensure_service_up(ping) on launch

The service outlives the instance — stop the stack and the sidecar keeps running, so the next instance (or another stack) attaches to the same state.

Attaching from a recipe

A recipe references a service via mcp.servers[].service. The assembler resolves the name to a hatago URL-proxy entry, so hatago proxies the network-native server:

# catalog/recipes/ping/recipe.yaml
mcp:
  servers:
    - name: ping
      service: ping        # ← resolved to http://ping:8080/mcp
      transport: http

Networking note: by default stacks use rootless (pasta) networking, so pod members reach a shared service via the host gateway host.containers.internal:<port>. That is why server.py adds host.containers.internal to FastMCP's allowed hosts. On hosts that support rootless bridges, set HARNESSED_NET=<name> and members resolve the service by DNS name instead (http://<name>:<port>).

See also

Clone this wiki locally