Docs Self-host for a team

Self-host for a team

Run one container, connect each machine with coding.sh init --remote, and open the team viewer.

Run one coding.sh serve that the whole team sends sessions to and reads from. One port (4318) takes OpenTelemetry traces and sessions from each developer's agents, and serves the viewer and the API.

Quickstart

1. Run the server on any machine with Docker that your team can reach:

shell
docker run -d --name coding-sh -p 4318:4318 -v coding-sh:/data --restart unless-stopped ghcr.io/jackulau/coding.sh
docker logs coding-sh

The first start makes a read key and an ingest key, keeps them in the coding-sh volume, and prints them. Every later start prints the same keys:

output
http://127.0.0.1:4318
No keys are configured, so serve uses the keys in /data/serve-keys.json (made now):
  read key    csh_rk_…
  ingest key  csh_ik_…
  Open        http://127.0.0.1:4318/?t=csh_rk_…
  Connect     coding.sh init --remote http://127.0.0.1:4318 --key csh_ik_…

The URLs are the address inside the container. Use the server's own host name or IP instead.

2. Connect each developer's machine with the ingest key:

shell
coding.sh init --remote http://<server>:4318 --key csh_ik_…

New sessions from the coding agents on that machine now go to the server too, as metadata (no prompts, code or tool output). Install coding.sh first if needed: curl -fsSL https://coding.sh/install | sh.

3. Open the viewer at http://<server>:4318/?t=csh_rk_…, or open http://<server>:4318 and paste the read key into the form.

KeyCanGive it to
ingest (csh_ik_…)only send; sessions land in the default projectdevelopers and apps
read (csh_rk_…)open the viewer, the API (Authorization: Bearer csh_rk_…) and /metricspeople who read

Keys are made only when no key and no viewer login is configured. To make new ones, delete serve-keys.json from the volume and restart; the old keys stop working.

Run it with Compose

deploy/compose/ has three stacks. None needs edits; optional settings go in deploy/compose/.env (copy .env.example).

FileStoreUse it when
compose.ymlSQLite on the coding-sh-data volumethe default; one team, one machine
compose.postgres.ymlPostgres 17 in the stackyou want Postgres, or the CLI on the team database
compose.clickhouse.ymlClickHouse in the stack, blobs on the volumeyou keep a lot of sessions or want SQL over them (ClickHouse)

From a checkout:

shell
docker compose -f deploy/compose/compose.yml up -d --wait
docker compose -f deploy/compose/compose.yml logs coding-sh     # the keys and the connect command

Only port 4318 is published, not the database ports. docker-compose.yml at the repo root is a different stack, used by the store tests (production/compose.md).

Connect

init --remote checks the key against the server first, and changes nothing if the server is unreachable or refuses it. Then it installs the capture hooks, as coding.sh init does, and adds a sink named team to ~/.coding.sh/config.toml. Running it again replaces that sink.

OptionWhat it does
--remote-mode fullAlso sends prompts, code and tool output, redacted on the machine first. Decide as a team.
CODING_SH_INGEST_KEY=csh_ik_… coding.sh init --remote <url>Keeps the key out of shell history.
https:// URLUse it over the internet (TLS). Plain http:// sends the key in the clear.

Sessions are sent when the agent's hook runs at the end of each turn, and whenever a coding.sh command runs. Sessions captured before you connected stay on the machine.

Connect by hand

This is the block init --remote writes. It works on any coding.sh version:

config.toml
[[sinks]]
name = "team"
kind = "http"
endpoint = "http://<server>:4318/v1/ingest"
mode = "metadata" # "full" also sends prompts, code and tool output
headers = { Authorization = "Bearer csh_ik_…" }

Run coding.sh ingest, then look for your session in the viewer.

Apps and SDKs

Set these where the app runs. The TypeScript and Python SDKs read them:

shell
CODING_SH_URL=http://<server>:4318
CODING_SH_INGEST_KEY=csh_ik_…

A plain OpenTelemetry app uses OTEL_EXPORTER_OTLP_ENDPOINT=http://<server>:4318 and OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer%20csh_ik_…". Never ship an ingest key to a browser. See Trace your own agent.

Your own keys and one project per person

The project comes from the ingest key. With one key per person, sessions show up as alice/<id>, and GET /api/sessions?project=alice filters by person:

shell
docker run --rm ghcr.io/jackulau/coding.sh /app/cli.mjs serve keygen --project alice
docker run --rm ghcr.io/jackulau/coding.sh /app/cli.mjs serve keygen --project '*' --read

Each command prints the key once on stdout, and on stderr the setting that holds only its sha256 hash. Give each key to its owner, and put the hashes in deploy/compose/.env (or pass them with -e):

shell
CODING_SH_INGEST_KEYS=alice:sha256:<hex>,bob:sha256:<hex>,billing-api:sha256:<hex>
CODING_SH_READ_KEYS=*:sha256:<hex>

Set both, or serve refuses to start on a public address. Then run docker compose -f deploy/compose/compose.yml up -d again. To revoke a key, remove its hash and restart. Per-project retention and a shared viewer login are in guides/team.md.

TLS

serve speaks plain HTTP. On the internet, put a proxy in front and keep 4318 on the loopback interface. With Caddy, which gets the certificate itself:

shell
# deploy/compose/.env
CODING_SH_PUBLISH=127.0.0.1:4318
CODING_SH_TRUST_PROXY=1
caddyfile
obs.example.com {
	reverse_proxy 127.0.0.1:4318
}

Then connect with coding.sh init --remote https://obs.example.com --key csh_ik_…. Fly, Railway and Render terminate TLS for you (production/).

Data, backups and upgrades

TaskHow
Find the dataindex.db, blobs/ and serve-keys.json in the volume; Postgres in pgdata, ClickHouse in chdata
Back upCopy the volumes or the database. For SQLite, stop the container first (below)
Upgradedocker pull ghcr.io/jackulau/coding.sh and run it again, or Compose pull and up -d. The schema migrates on start
Pin a versionghcr.io/jackulau/coding.sh:0.1.2, or CODING_SH_IMAGE in .env
Check healthcurl -s http://<server>:4318/readyz answers {"ok":true,…} once the store is ready
Delete old sessionsCODING_SH_RETENTION_DAYS=30 deletes sessions older than 30 days, every hour
shell
docker stop coding-sh && docker run --rm -v coding-sh:/data -v "$PWD":/backup alpine tar czf /backup/coding-sh.tgz -C /data . && docker start coding-sh

Read from the CLI

People with database access can run the CLI and MCP tools against the team database. This needs the coding.sh-store-postgres package next to coding.sh. With --store, local spool files are not pushed into the shared database:

shell
coding.sh sessions --store 'postgres://coding:…@db-host:5432/coding' --project alice

ClickHouse

There are two ways to use ClickHouse, and they can run together:

Store pluginCollector route
What goes to ClickHouseEverything coding.sh stores: sessions, events, search text, eval assetsSpan metadata only, in the collector's otel_traces table
Who reads itcoding.sh (viewer, CLI, MCP) and your own SQLYour own SQL or Grafana; coding.sh collector import can pull agent traces back
What runscoding.sh serve with --store clickhouse://…Your OpenTelemetry Collector with the clickhouse exporter
Pick it whenClickHouse is the database you want coding.sh to run onYou already collect traces into ClickHouse

Run the server on ClickHouse

Pick ClickHouse when your team already runs it, keeps a lot of sessions, or wants analytical SQL over the tables coding.sh reads. Pick Postgres for a small team, or for transactional writes.

shell
docker run -d --name coding-sh -p 4318:4318 -v coding-sh:/data --restart unless-stopped \
  -e CODING_SH_STORE_URL='clickhouses://coding:pass@ch.example.com:8443/coding' \
  -e CODING_SH_BLOB_URL='file:///data/blobs' \
  ghcr.io/jackulau/coding.sh

With Compose: docker compose -f deploy/compose/compose.clickhouse.yml up -d --wait.

  • The image has the plugin. Outside Docker, install coding.sh-store-clickhouse next to coding.sh.
  • clickhouse://…:8123/db is HTTP and clickhouses://…:8443/db is HTTPS. The plugin uses the HTTP interface, not port 9000.
  • Blobs never go to ClickHouse: keep them on file:// or on S3 with coding.sh-store-s3.
  • One serve writes at a time; another takes over within 30 s if it dies.

URL parameters, transactions, replication and search are in the store plugin's README.

Export to ClickHouse through an OpenTelemetry Collector

If your traces already go through the Collector's clickhouse exporter, send coding.sh sessions there too. coding.sh keeps its own store, and an otlp sink copies each session's spans to the collector, which writes them to otel.otel_traces. You query that table; coding.sh does not read it.

deploy/clickhouse/ has a working pair, otel-collector.yaml and docker-compose.yml, pinned, with ports on 127.0.0.1:

shell
export CLICKHOUSE_PASSWORD=$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')
docker compose -f deploy/clickhouse/docker-compose.yml up -d --wait

The collector part of the config, to merge into your own:

yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
exporters:
  clickhouse:
    endpoint: tcp://clickhouse:9000?dial_timeout=10s
    database: otel
    username: ${env:CLICKHOUSE_USER}
    password: ${env:CLICKHOUSE_PASSWORD}
    ttl: 720h
    create_schema: true
    # json: true          # attributes as the JSON type; needs enable_json_type=1 on the server
    sending_queue:
      batch:
        flush_timeout: 5s
service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [clickhouse]

Then add a sink on each machine that captures sessions. The Compose file publishes the collector on 4319, so it can share a host with serve on 4318:

config.toml
[[sinks]]
kind = "otlp"
name = "clickhouse"
endpoint = "http://collector:4319"
mode = "metadata"

On a team, set CODING_SH_FORWARD_URL=http://collector:4319 on serve instead, and it forwards every trace it commits (Forward and sinks).

What lands in otel_traces, with ServiceName = 'coding.sh':

SpanNameOne perAttributes (SpanAttributes['…'])
invoke_agent <agent>session (an open one can have several rows)gen_ai.operation.name, gen_ai.agent.name, gen_ai.request.model, gen_ai.conversation.id (the session id), gen_ai.usage.*, coding_sh.cost_usd, coding_sh.stats.* (events, turns, tool_calls, tool_errors, files_changed, tests_passed, tests_failed), coding_sh.session.flags, vcs.ref.head.revision
execute_tool <tool>tool callgen_ai.operation.name, gen_ai.tool.name, gen_ai.tool.call.id, gen_ai.agent.name; StatusCode is Error when the call failed
chat <model>model callgen_ai.operation.name, gen_ai.request.model, gen_ai.provider.name, gen_ai.usage.*, coding_sh.cost_usd

gen_ai.usage.* is input_tokens, output_tokens, cache_read.input_tokens and cache_write.input_tokens. input_tokens is the whole prompt, cache included; the uncached part is coding_sh.usage.uncached_input_tokens. Through 0.1.5 cache writes are also sent as cache_creation.input_tokens, so query cache_write.input_tokens. A call with reasoning tokens also gets reasoning.output_tokens. A session's spans share one TraceId, subagents join their parent's trace, and Duration is in nanoseconds.

Cost per agent and model per day:

sql
SELECT
    toDate(Timestamp) AS day,
    SpanAttributes['gen_ai.agent.name'] AS agent,
    SpanAttributes['gen_ai.request.model'] AS model,
    count() AS sessions,
    round(sum(toFloat64OrZero(SpanAttributes['coding_sh.cost_usd'])), 2) AS cost_usd,
    sum(toUInt64OrZero(SpanAttributes['gen_ai.usage.input_tokens'])) AS input_tokens,
    sum(toUInt64OrZero(SpanAttributes['gen_ai.usage.output_tokens'])) AS output_tokens
FROM (
    SELECT Timestamp, SpanId, SpanAttributes
    FROM otel.otel_traces
    WHERE ServiceName = 'coding.sh'
      AND SpanAttributes['gen_ai.operation.name'] = 'invoke_agent'
      AND Timestamp >= now() - INTERVAL 30 DAY
    ORDER BY toUInt64OrZero(SpanAttributes['coding_sh.stats.events']) DESC
    LIMIT 1 BY SpanId
)
GROUP BY day, agent, model
ORDER BY day DESC, cost_usd DESC

The inner query keeps one row per session: an open session is sent again as it grows (see "Things to know"), and the row with the most events (coding_sh.stats.events) wins.

Slow and failing tools:

sql
SELECT
    SpanAttributes['gen_ai.tool.name'] AS tool,
    count() AS calls,
    countIf(StatusCode = 'Error') AS errors,
    round(errors / calls, 3) AS error_rate,
    round(quantile(0.95)(Duration) / 1e6) AS p95_ms,
    round(max(Duration) / 1e6) AS max_ms
FROM otel.otel_traces
WHERE ServiceName = 'coding.sh'
  AND SpanAttributes['gen_ai.operation.name'] = 'execute_tool'
  AND Timestamp >= now() - INTERVAL 7 DAY
GROUP BY tool
ORDER BY p95_ms DESC
LIMIT 20

Failed sessions: tests failed, or coding.sh flagged repeated tool failures or a premature finish:

sql
SELECT
    Timestamp AS started,
    SpanAttributes['gen_ai.conversation.id'] AS session,
    SpanAttributes['gen_ai.agent.name'] AS agent,
    toUInt32OrZero(SpanAttributes['coding_sh.stats.tool_errors']) AS tool_errors,
    toUInt32OrZero(SpanAttributes['coding_sh.stats.tests_failed']) AS tests_failed,
    JSONExtract(SpanAttributes['coding_sh.session.flags'], 'Array(String)') AS flags,
    round(Duration / 1e9) AS duration_s
FROM (
    SELECT Timestamp, SpanId, SpanAttributes, Duration
    FROM otel.otel_traces
    WHERE ServiceName = 'coding.sh'
      AND SpanAttributes['gen_ai.operation.name'] = 'invoke_agent'
      AND Timestamp >= now() - INTERVAL 7 DAY
    ORDER BY toUInt64OrZero(SpanAttributes['coding_sh.stats.events']) DESC
    LIMIT 1 BY SpanId
)
WHERE tests_failed > 0 OR hasAny(flags, ['repeated-tool-failure', 'premature-completion'])
ORDER BY started DESC
LIMIT 50

One session's spans, in order (clickhouse-client --param_session=<session id>):

sql
SELECT
    Timestamp,
    SpanName,
    round(Duration / 1e6) AS ms,
    StatusCode,
    SpanAttributes['coding_sh.cost_usd'] AS cost_usd
FROM (
    SELECT Timestamp, SpanId, SpanName, Duration, StatusCode, SpanAttributes
    FROM otel.otel_traces
    WHERE TraceId = (
        SELECT any(TraceId) FROM otel.otel_traces
        WHERE SpanAttributes['gen_ai.conversation.id'] = {session:String}
    )
    ORDER BY toUInt64OrZero(SpanAttributes['coding_sh.stats.events']) DESC
    LIMIT 1 BY SpanId
)
ORDER BY Timestamp

Things to know:

  • Map columns store every attribute as a string: cast with toFloat64OrZero and toUInt64OrZero. coding_sh.session.flags is a JSON array string.
  • Cost is on the invoke_agent span (the session) and on each chat span. Sum one, never both.
  • mode = "full" adds prompts, code and tool output as span attributes.
  • The invoke_agent span is sent when the session ends. coding.sh serve and coding.sh collector also send it while a session is open: after 10 quiet seconds, and every 60 seconds while busy.
  • Each send is a new row with the same TraceId and SpanId and newer totals. A session keyed by a conversation id never ends, so those are its only rows.
  • A laptop sink (coding.sh ingest) sends the span at the session's end only.
  • ttl: 720h drops rows after 30 days. Set it to your retention.

Pull from ClickHouse

If your agents already send traces to a Collector with the clickhouse exporter (your own, ClickStack or HyperDX), coding.sh collector import reads them into your local store:

shell
CLICKHOUSE_USER=otel CLICKHOUSE_PASSWORD=… \
  coding.sh collector import --clickhouse "http://127.0.0.1:8123/?database=otel"
  • Each row is mapped as a span posted to coding.sh collector would be: the same events, redaction and pricing.
  • It reads only traces with an agent span unless you pass --all, and never stores a span twice.
  • It keeps its place per table and row filter (--all, each --where). The next run starts an hour before it.
  • --since or --from-start reads from that time again, for a root span that landed over an hour late. Leave both out of a scheduled import.
  • It stores 5,000 rows at a time and retries a dropped connection or a 502, 503 or 504 three times. A run that still fails prints the command to run again.
  • Both the default Map(String, String) columns and json: true columns work. In Map columns a string that looks like a number (an id such as "42") comes back as a number.

--since, --from-start, --where, --table and --dry-run are on coding.sh collector.

Next