-
Notifications
You must be signed in to change notification settings - Fork 0
service authoring
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/.
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 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>`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 withpodman porton 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 gitignoredmise.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. Requiresscope: project, and is mutually exclusive withpublish. 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).
ping is the smallest shared-service sidecar — one ping MCP tool over Streamable HTTP, no
external state. All three files:
name: ping
image: harnessed-ping:latest
volume: ping-data
port: 8080
healthcheck: "curl -sf http://localhost:8080/health || exit 1"-
image: harnessed-ping:latest—svc upbuilds this from the service's ownDockerfileon first use (and the build-time BLD-02 image scan gates it). -
volume: ping-data— service-scoped; it survivessvc downby default (that's the value — one memory across instances).--purgeis the explicit destroy. -
healthcheck— whatsvc uppolls to confirm readiness before returning.
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.
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+ optionalGET/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 inCLAUDE.md). - A separate
/healthroute lets the containerHEALTHCHECK(andsvc up) probe readiness without speaking MCP. - The
allowed_hostsentry forhost.containers.internalmatters on the default rootless networking model (see below).
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 volumeThe 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 launchThe service outlives the instance — stop the stack and the sidecar keeps running, so the next instance (or another stack) attaches to the same state.
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: httpNetworking 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>).
- docs/harnessed-design.md §3 & §9 — the why (runtime pod, service-scoped state & lifecycle).
-
Recipe-authoring guide — the service-ref MCP shape (
service:/transport: http). -
Stacks guide — declaring
services:in a stack manifest. -
catalog/services/ping/— the worked example (manifest + image + server). -
src/harnessed/schema.py— the typedServiceDefmodel.
Start Here
Guides
- Recipe authoring
- Service authoring
- Stacks
- Extending stacks (proposed)
- Recipe catalog
- System prompt & rules (proposed)
- Secrets
- AWS SSO
- Pulumi (host login forwarding)
- Egress & exposing services
- Container filesystem
- Git hooks
- Troubleshooting
- Pin management (harnessed update)
Codebase Map
Planning & Roadmap
- open work: GitHub Issues
Research & Prompts
- research/ (home-folder requirements per harness, browse in-repo)
- prompts/ (reusable prompt templates, browse in-repo)