Docs/Start Here/Timebase 2.0 Docker Agent Runbook
PlatformHow-to

Timebase 2.0 Docker Agent Runbook

The Docker deployment and security walkthrough written for execution by a coding agent - concrete values, a verification after every phase, and explicit stopping points where a human is required.

Updated 5 Nov 2026Timebase Docs

This is the executable companion to Start Here for Docker: How to Deploy and Secure Timebase 2.0. It covers the same ground in the same order, written for a coding agent to carry out rather than for a person to read.

To use it, give the whole of this page to your agent - copy it, or hand over the runbook file. It is self-contained: the Compose file is embedded, every value is concrete, and nothing here depends on reading the companion article.

The agent starts by asking you two plain questions: whether Timebase will be used only on this computer or by other computers on your network too, and whether you want the AI assistant ready now - with Anthropic's Claude, with another provider, or later. After that, a few points still need you, because they involve credentials an agent must not handle or rights it does not have: the administrator password, the key for your AI provider if you want one, each component's client secret (you copy them from Pulse and paste them all at once into a small helper the agent gives you to run, or one at a time into each component's own setup form, as you prefer), the sign-in addresses in Pulse if other computers will use it, and the commands that need administrator rights. The runbook stops at each and waits. Your user accounts and the AI usage limits are not required: it offers them as next steps at the end.

Important

read Start Here for Docker as well. This page tells an agent what to do; that one explains why each step matters, which is what you will want when something does not behave as expected.

An executable companion to Start Here for Docker: How to Deploy and Secure Timebase 2.0. Hand this file to a coding agent to deploy and secure a Timebase 2.0 stack on a single Docker host.


Instructions to the agent#

Work through the phases in order. Do not skip ahead: each phase depends on the one before it.

Rules

  1. Run the verification command at the end of every phase and confirm the expected result before continuing. If it does not match, stop and report - do not proceed hopefully.
  2. Every command here is non-interactive. Never launch an editor inside a container (docker exec -it ... nano will hang you).
  3. Phases marked STOP - HUMAN REQUIRED cannot be completed by you. Print exactly what you need, wait for the human to supply it, then continue.
  4. Address services through Compose, never by container name. This stack sets no container_name, so Docker generates names like timebase-pulse-1. Use docker compose cp, docker compose exec, docker compose restart, docker compose logs and docker compose ps, which all take the short service name, and run them from ./timebase. Do not add a container_name to the Compose file: a container name is global in Docker rather than scoped to a project, so it would collide with any other container of that name. Commands here are written cd ./timebase && ..., for a fresh shell that starts in the folder that holds ./timebase. A shell that keeps its working directory between commands is already in ./timebase after the first one, and the second cd ./timebase fails: run each command in a subshell, (cd ./timebase && ...), or write the absolute path in place of ./timebase. If the human named a folder to work in, create it first if it does not exist, and start from inside it.
  5. Never invent a secret, an API key, or a password. Never read one out of a log and paste it somewhere else without being asked to.
  6. Re-running a phase must be safe. Each phase begins with a check for work already done.
  7. When checking whether something is now fixed, read a time window, not a line count. Use docker compose logs <svc> --since 2m, never --tail <n>: a tail can include lines from before the restart you just did, and make a working fix look broken.
  8. Every command you hand to a human must be runnable as pasted. They are not in your shell and do not know your working directory. Resolve ./timebase to an absolute path once with pwd, state it plainly, and prefix any command that depends on it with cd <that absolute path> && . On Windows write cd <that absolute path>; <command> instead, because Windows PowerShell 5.1 has no &&, and give the path in its C:\... form (Phase 6 shows how). Never hand over a bare relative path. Put the command in the message that ends your turn, or inside the question you ask: text printed just before a question prompt can be lost, and the human then has to ask for it again.
  9. Never write to a component's configuration files. That covers settings.config, historians.config, sources.config and anything else in a component's settings or config folder: do not edit one, and do not copy an edited one back in. When a component needs something from a human, the human enters it in that component's own interface. Tell them which screen and which fields, give them every value that is not a secret, and wait. The files this runbook has you write are the deployment itself: the Compose file and .env, and, if the human chooses it in Phase 7, a helper script that holds no secret.
  10. Never see a secret. Each client secret goes from Pulse, through the human's clipboard, into that component's own setup form, and nowhere else: not into a file, not into an environment variable, not through your shell. The human puts it there themselves, either in the form or through the helper script they run in a terminal of their own. Do not ask the human to give you one, and do not open a component's settings.config, which holds its secret once the form has saved it.

Assumed values. Substitute nothing unless the human tells you otherwise.

Value Use
localhost How the human's browser reaches components, unless step 1b recorded another address. That address is <address> below and TIMEBASE_HOST in .env
pulse The Compose service name: how the containers reach Pulse
https://localhost:4542 Pulse's public address, Auth__PublicAddress: where a browser reaches it, and, with /auth added, the token issuer. With another address in step 1b it is https://<address>:4542
./timebase Working directory for the Compose file, .env, the downloaded root CA and, if used, the helper script
Administrator / timebase The first-run Pulse sign-in, identical on every installation. Needed from Phase 7 onward. Case-sensitive: Administrator, not administrator

Phase 1 - Preflight#

docker --version && docker compose version && (python3 --version && echo "use: python3" || (python --version && echo "use: python"))

Compose must be v2 or newer (docker compose, not docker-compose). Python 3 is needed well beyond the tag check in step 2a: Phases 4, 5 and 7 run it too. On Windows it is usually python, not python3: the check ends with use: python3 or use: python, and wherever a command here says python3, use the one it named. If either is missing, stop and report it. The human's own terminal needs Python 3 as well, to run the helper in Phase 7, and you cannot check it from here: Route 2 in that phase needs none.

Report the CPU count and memory that Docker can use, which on Docker Desktop is less than the host's, and state that the documented minimum is 4 cores and 8 GB for a single-machine deployment of the full stack:

docker info --format '{{.NCPU}} CPUs, {{.MemTotal}} bytes'

Then look for an earlier stack, because one changes what the rest of this runbook does:

docker compose ls -a
docker volume ls

Look for a project named timebase, or for volumes named after it, such as timebase_pulse. If either exists, tell the human what you found (whether it is running, and which volumes) and ask whether to reuse it, which is an upgrade, or start clean. Wait for the answer. A reused stack keeps its old root CA, its client secrets and its Administrator password, so later phases may find their work already done. Starting clean deletes the old volumes and everything in them: say so, and remove nothing until they have clearly said yes.

Then check that nothing on the host already listens on the fourteen ports this stack publishes, 4511-4512, 4521-4522, 4531-4532, 4541-4542, 4551-4552, 4561-4562 and 4571-4572 (a stack the human chose to reuse holds them itself, and that is expected):

ss -ltn 2>/dev/null | grep -E ':45[1-7][12] ' || echo "ports free"

Where ss is not installed, as in Git Bash on Windows, that line prints a false ports free. Use the PowerShell command there.

On Windows, in PowerShell (it lists only the fourteen ports, so an unrelated listener elsewhere between 4511 and 4572 does not trip it):

Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 4511,4512,4521,4522,4531,4532,4541,4542,4551,4552,4561,4562,4571,4572 } | Select-Object LocalPort, OwningProcess

A Windows installation of Timebase uses the same ports. Its hosts run under the Windows service Timebase Service Starter, and they will hold every one of these ports. Pulse's seeded sign-in callbacks are fixed to https://localhost:45x2/callback, so the Docker stack cannot simply move to other ports. On Windows, check whether that service is running, in PowerShell (it prints nothing if the service is not installed):

Get-Service 'Timebase Service Starter' -ErrorAction SilentlyContinue

If it is running, STOP - HUMAN REQUIRED: stopping a service needs administrator rights. Hand over, for an Administrator PowerShell session:

Stop-Service 'Timebase Service Starter'

Tell them the service is stopped, not disabled, so it returns on the next reboot and takes the ports back unless they also set it to Manual (Set-Service 'Timebase Service Starter' -StartupType Manual).

Verify: Docker responds, Compose v2 or newer is available, Python 3 is available, any earlier stack has been reported and the human has chosen to reuse it or start clean, and the port check prints nothing (or ports free), or shows only a stack they chose to reuse.

1b. Ask how Timebase will be reached#

Everything below defaults to one computer, reached as localhost. Before you write a file, find out whether that is what the human wants. Ask in these words, not in technical terms, and wait for the answer:

Will you use Timebase only on this computer, or will other computers on your network need to reach it too?

  • Only this computer. Also the answer to assume if they are unsure or only trying it out. Nothing more to ask: the address is localhost. Go on to Phase 2.
  • Other computers too. Ask the next question.
  • Through a company web address, proxy or load balancer that forwards to this computer. This runbook does not cover that: a component builds its sign-in address from the address the browser used, and a proxy changes it. Stop and tell them so.

What name or address will people type to reach this computer? For example timebase.yourcompany.com or 10.4.2.17.

Take a DNS name or an IPv4 address. Remove any https://, port and path, write it in lower case, and read it back: "So people will open Timebase at https://<address>:4542, and the other pages on their own ports - is that right?" If they answer localhost or 127.0.0.1, that is the first answer. IPv6 addresses and wildcard names are not covered: ask for another.

Record it as <address>. Step 2b writes it to .env, and it changes only these things:

Setting Only this computer Other computers, at <address>
Pulse's public address, Auth__PublicAddress https://localhost:4542 https://<address>:4542
Names in Pulse's certificate, Cert__Names pulse,localhost pulse,<address>
Names in each other component's certificate, Pulse__Cert__Names its service name and localhost its service name and <address>
Sign-in addresses Pulse accepts the localhost ones it ships with those, plus six for <address>, which the human adds in Phase 7
Pulse's own assistant, Gage__SelfAddress https://localhost:4542 https://localhost:4542

Tell them in plain words what to expect. For the first answer, nothing more is needed. For the second, they will register that address in Pulse, and every computer that opens Timebase must be able to look the name up and must trust Pulse's certificate.

1c. Ask about the AI assistant#

Timebase's AI assistant, Gage, answers questions by calling an AI model that the human supplies: Pulse holds the credentials, and every component asks Pulse. Pulse already contains a provider for Anthropic's Claude, waiting only for a key. Find out now what the human wants, so that their one visit to Pulse in Phase 7 covers it. Ask in these words, and wait for the answer:

Do you want the AI assistant to be ready to answer questions once this is set up? It needs an account with an AI provider. You can use Anthropic's Claude, which is already prepared and only needs your API key. You can use another provider, such as OpenAI or Google Gemini, or a model you run yourself. Or you can leave it for later.

If they just say yes, ask which: Anthropic, another provider, or later.

  • Anthropic (Claude). They need an Anthropic API key. Nothing else to decide.
  • Another provider. Ask which one, and whether it runs in the cloud or on a computer they run. They will need its address, an API key if it uses one, and the name of the model to use. A model running on the Docker host itself needs care: see The AI provider in Phase 7.
  • Later, or not at all. Also the answer to assume if they are unsure. Nothing breaks: the assistant answers nothing until a provider has a key, and Phase 9 offers it as a next step.

Ask them to have the key ready before Phase 7, in a place they can copy it from. You never see it: they enter it in Pulse themselves, in Phase 7.


Phase 2 - Write the Compose file#

Create the working directory and write ./timebase/docker-compose.yml with exactly the contents below. It defines seven component services - pulse, historian, explorer, atlas, connect, bridge, collector - with seven named volumes of the same names, plus volume-init, a one-shot service that runs before them and exits.

Do not alter the service names: they are the hostnames the components use to find each other, and the names their certificates are issued for.

Every image reads ${TIMEBASE_TAG}, which defaults to latest, the newest 2.0 release, so the whole stack moves together. Never edit a tag in this file. To pin a release, set TIMEBASE_TAG in .env (step 2a).

.env carries one more variable, TIMEBASE_HOST: the address from step 1b. It is optional and defaults to localhost, so a local run needs no entry for it. Every address-bearing setting in the file reads it, so a changed answer is one line in .env and a docker compose up -d, never an edit to this file.

mkdir -p ./timebase

2a. Start the .env file#

Write .env beside the Compose file, where Compose reads it automatically. Its first line is the release tag. Use latest unless the human asked for a particular release, such as 2.0.0:

cd ./timebase && echo "TIMEBASE_TAG=latest" > .env

latest always points at the newest 2.0 release. Docker does not refresh an image it already holds on its own, which is why every up in this runbook passes --pull always: keep it.

2b. Record how Timebase will be reached#

If step 1b gave localhost, write nothing: the Compose file defaults to it. Otherwise append the address to the .env that 2a just wrote. 2a replaces the file on every run, so do this after it, every run:

cd ./timebase && echo "TIMEBASE_HOST=<address>" >> .env

Use the lower-case address from step 1b, with no https://, no port and no trailing slash. Then show the human .env, which holds no secret: the tag, and the address if there is one.

2c. Write the Compose file#

# Upgrading an existing stack? Keep YOUR existing project name on the next line: Compose
# names volumes under it, and changing it mounts a fresh, empty set instead of your data.
name: timebase

services:
  # Runs once, as root, before anything else, and exits. From 2.0 the images run as
  # an unprivileged user (uid 1654), so a volume created by an earlier release, which
  # ran as root, has to be handed over first. A volume that is already right costs
  # one stat. `$$` is how Compose writes a literal `$`.
  # Keep it on a fresh installation too: it also hands over a volume first mounted at a
  # path its mounting image does not pre-create; a correct volume costs only that stat.
  volume-init:
    image: timebase/pulse:${TIMEBASE_TAG:-latest}
    user: "0"
    restart: "no"
    network_mode: none
    entrypoint: ["sh", "-c"]
    command:
      - |
        for d in /pulse /historian /explorer /atlas /connect /bridge /collector; do
          if [ "$$(stat -c %u "$$d")" != "1654" ]; then
            echo "volume-init: $$d is owned by uid $$(stat -c %u "$$d"); handing it to uid 1654"
            chown -R 1654:1654 "$$d"
          fi
        done
    volumes:
      - pulse:/pulse
      - historian:/historian
      - explorer:/explorer
      - atlas:/atlas
      - connect:/connect
      - bridge:/bridge
      - collector:/collector

  pulse:
    image: timebase/pulse:${TIMEBASE_TAG:-latest}
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 2048m
    hostname: pulse
    ports:
      - "4541:4541"
      - "4542:4542"
    restart: unless-stopped
    depends_on:
      volume-init:
        condition: service_completed_successfully
    volumes:
      - pulse:/pulse
    environment:
      - Certificates=/pulse/certificates
      - Settings=/pulse/settings
      - Config=/pulse/config
      - Data=/pulse/data
      - Logs=/pulse/logs
      # The names this host's certificate must carry, beyond localhost. Every
      # other container dials Pulse as `pulse`, and a certificate is only good
      # for the names inside it. TIMEBASE_HOST comes from .env and defaults to
      # localhost. See "Certificate names" below.
      - Cert__Names=pulse,${TIMEBASE_HOST:-localhost}
      # Where a BROWSER reaches Pulse. Without it the sign-in redirect sends
      # the human's browser to https://pulse:4542, which resolves only inside
      # this network. See Phase 5.
      - Auth__PublicAddress=https://${TIMEBASE_HOST:-localhost}:4542
      # Where Pulse's own assistant reaches Pulse: always itself, so it never
      # depends on reaching its own public name from inside the container.
      - Gage__SelfAddress=https://localhost:4542

  historian:
    image: timebase/historian:${TIMEBASE_TAG:-latest}
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 4096m
    hostname: historian
    ports:
      - "4511:4511"
      - "4512:4512"
    restart: unless-stopped
    depends_on:
      volume-init:
        condition: service_completed_successfully
    stop_grace_period: 60s
    volumes:
      - historian:/historian
    environment:
      - Certificates=/historian/certificates
      - Settings=/historian/settings
      - Pulse__Cert__Names=historian,${TIMEBASE_HOST:-localhost}
      - Data=/historian/data
      - Logs=/historian/logs

  explorer:
    image: timebase/explorer:${TIMEBASE_TAG:-latest}
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 2048m
    hostname: explorer
    ports:
      - "4531:4531"
      - "4532:4532"
    restart: unless-stopped
    depends_on:
      volume-init:
        condition: service_completed_successfully
    volumes:
      - explorer:/explorer
    environment:
      - Certificates=/explorer/certificates
      - Settings=/explorer/settings
      - Pulse__Cert__Names=explorer,${TIMEBASE_HOST:-localhost}
      - Config=/explorer/config
      - Data=/explorer/data
      - Logs=/explorer/logs

  atlas:
    image: timebase/atlas:${TIMEBASE_TAG:-latest}
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 4096m
    hostname: atlas
    ports:
      - "4551:4551"
      - "4552:4552"
    restart: unless-stopped
    depends_on:
      volume-init:
        condition: service_completed_successfully
    volumes:
      - atlas:/atlas
    environment:
      - Certificates=/atlas/certificates
      - Settings=/atlas/settings
      - Pulse__Cert__Names=atlas,${TIMEBASE_HOST:-localhost}
      - Config=/atlas/config
      - Logs=/atlas/logs
      - Data=/atlas/data

  connect:
    image: timebase/connect:${TIMEBASE_TAG:-latest}
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 2048m
    hostname: connect
    ports:
      - "4561:4561"
      - "4562:4562"
    restart: unless-stopped
    depends_on:
      volume-init:
        condition: service_completed_successfully
    volumes:
      - connect:/connect
    environment:
      - Certificates=/connect/certificates
      - Settings=/connect/settings
      - Pulse__Cert__Names=connect,${TIMEBASE_HOST:-localhost}
      - Config=/connect/config
      - Logs=/connect/logs
      - Data=/connect/data

  bridge:
    image: timebase/bridge:${TIMEBASE_TAG:-latest}
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 2048m
    hostname: bridge
    ports:
      - "4571:4571"
      - "4572:4572"
    restart: unless-stopped
    depends_on:
      volume-init:
        condition: service_completed_successfully
    volumes:
      - bridge:/bridge
    environment:
      - Certificates=/bridge/certificates
      - Settings=/bridge/settings
      - Pulse__Cert__Names=bridge,${TIMEBASE_HOST:-localhost}
      - Config=/bridge/config
      - Logs=/bridge/logs
      - Data=/bridge/data

  collector:
    image: timebase/collector:${TIMEBASE_TAG:-latest}
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 4096m
    hostname: collector
    ports:
      - "4521:4521"
      - "4522:4522"
    restart: unless-stopped
    depends_on:
      volume-init:
        condition: service_completed_successfully
    volumes:
      - collector:/collector
    environment:
      - Active=false
      - Certificates=/collector/certificates
      - Settings=/collector/settings
      - Pulse__Cert__Names=collector,${TIMEBASE_HOST:-localhost}
      - Config=/collector/config
      - Data=/collector/data
      - Logs=/collector/logs

volumes:
  pulse:
  historian:
  explorer:
  atlas:
  connect:
  bridge:
  collector:

Verify the file parses and that every image resolved to the tag from step 2a. All eight lines must show the same tag, the one in .env, and none may be empty; volume-init reuses the Pulse image, which is why there are eight lines for seven components. Note the image for Connect is timebase/connect: there is no timebase/connector repository on Docker Hub.

cd ./timebase && docker compose config --quiet && docker compose config | grep "image:"

If the lines disagree, or any shows a bare timebase/x:, the .env file is wrong. Go back to step 2a rather than continuing.

Verify the address from step 1b reached every setting that carries it. No secret can match this filter:

cd ./timebase && docker compose config | grep -E "Auth__PublicAddress|Cert__Names"

Pulse's Auth__PublicAddress must read https://<address>:4542, its Cert__Names must read pulse,<address>, and each of the six Pulse__Cert__Names must read <service>,<address>, where <address> is localhost unless step 1b recorded another.


Certificate names#

A certificate is only accepted for the names inside it. Every container here dials its peers by the Compose service name, so each host's certificate has to carry its own service name or the connection fails with RemoteCertificateNameMismatch however good the trust chain is.

Cert__Names on Pulse, Pulse__Cert__Names on the other six, is that list. It is additive: localhost and 127.0.0.1 are always included and cannot be removed.

Do not rely on the container's hostname instead. A host's own machine name is added when a certificate is generated, but it is never treated as required, so it never causes one to be regenerated. A deployment that already has a localhost-only certificate on its volume, which is every upgrade, keeps it. A name in Cert__Names is required, so listing it forces the certificate to be reissued with it.

The address from step 1b is already in every list, through TIMEBASE_HOST. A DNS name becomes a DNS entry in the certificate and an IP address becomes an IP entry, so a browser that opens https://<address>:<port> finds the name it asked for. Pulse adds the host of its own public address by itself as well; the Compose file lists it anyway.

Add any further name this deployment is reached by - a second DNS name, a second IP address - to the lists in the Compose file, comma-separated, bare, with no scheme and no port. For example Cert__Names=pulse,timebase.plant.example,10.4.2.17.


Phase 3 - Start Pulse on its own#

Pulse comes first and alone, so that everything it needs can be settled before any other component exists (Phase 7). Naming it also starts volume-init, which Pulse depends on.

Check for work already done first. If Phase 1 found an earlier stack and the human chose to reuse it, the command below updates Pulse in place and keeps its volumes, so its root CA and every secret carry over. It starts and updates Pulse only: any other component of that stack keeps running as it was.

cd ./timebase && docker compose -f docker-compose.yml up --detach --pull always pulse

The first run pulls the Pulse image and may take a minute or two. The other six start in Phase 7.

Verify: Pulse reports running, and, on a fresh installation, no other component service does. The {{.State}} field emits running - it is {{.Status}} that reads Up About a minute, so do not match on Up here.

cd ./timebase && docker compose ps --format '{{.Name}} {{.State}}'

volume-init is not listed, and that is correct: it runs once before Pulse and exits, and docker compose ps lists running containers only. Verify it exited cleanly - the status must read Exited (0). Any other exit code means a volume could not be handed to the unprivileged user; read docker compose logs volume-init and STOP - HUMAN REQUIRED, because fixing a volume's owner is done outside the stack.

cd ./timebase && docker compose ps -a --format '{{.Service}} {{.Status}}' | grep volume-init

If Pulse is missing or restarting, read its log before changing anything:

cd ./timebase && docker compose logs pulse

Phase 4 - Check the token issuer#

The token issuer is how Pulse names itself: the iss claim in every token, and the issuer its discovery document advertises. It is not a setting. Pulse derives it from its public address, Auth:PublicAddress, plus /auth - and the Compose file in Phase 2 sets that address from TIMEBASE_HOST: https://localhost:4542 unless step 1b recorded another. A standard client, an MCP client among them, loads Pulse's discovery document from the issuer and checks that the document names the same issuer, so the issuer has to be an address that works, and this is how it stays one.

Confirm what Pulse is publishing:

curl -sk https://localhost:4542/auth/.well-known/openid-configuration \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print('issuer:', d['issuer']); print('token_endpoint:', d['token_endpoint'])"

Verify: the command returns without error and issuer is https://<address>:4542/auth, where <address> is localhost unless step 1b recorded another. Pulse writes the host in lower case. token_endpoint names the address you called, which from here is localhost: Pulse takes the token endpoint from the caller's host, always.

Report both values and continue to Phase 5, which settles where a browser is sent - and with it the issuer.


Phase 5 - Where the browser is sent to sign in#

Signing in to any component redirects the human's browser to Pulse. That redirect has to name an address their machine can resolve, and inside this network Pulse is pulse, which it cannot.

Auth:PublicAddress is the setting for exactly this, and the Compose file in Phase 2 already sets it from TIMEBASE_HOST, to https://localhost:4542 by default. It is where a browser reaches Pulse, and nothing else:

Endpoint Taken from
Sign-in and sign-out redirects Auth:PublicAddress, else the caller's host
Token, JWKS, userinfo The caller's host, always
The token issuer, iss Auth:PublicAddress plus /auth, else Pulse's HTTPS listener plus /auth

That split is why setting it needs no change on any component: the browser goes to the public address while the component still redeems the code over the back channel, as pulse.

Confirm Pulse is publishing it:

curl -sk https://localhost:4542/auth/.well-known/openid-configuration \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['authorization_endpoint'])"

Verify: the address printed is one the human's machine can open. It is https://<address>:4542/auth/authorize, which is https://localhost:4542/auth/authorize unless step 1b recorded another. If it prints https://pulse:4542/..., Auth__PublicAddress did not reach Pulse: check the environment line and recreate it.

Why it has to be stated. Without it Pulse falls back to the caller's host, and the caller is the component, not the human. So a browser signing in through Atlas was sent to https://pulse:4542 and died on a DNS error, while opening Pulse's own interface directly worked.

The issuer follows this address, so changing it later is not free: tokens issued under the old value stop validating. To change it, edit TIMEBASE_HOST in .env and run docker compose up -d, which recreates what changed, then add the matching sign-in addresses in Pulse (Phase 7). Expect everyone to sign in again.

If the human reaches this deployment by another address#

The Compose file already carries <address> into Pulse's public address and into every certificate, so there is nothing to set. What is left is whether the address works from their other computers. You are on the Docker host, where localhost works whatever <address> is, so your own curl proves nothing here.

An IP address needs no lookup. A name does: check that it resolves on this machine, then confirm it from one of the other computers, as the last step below.

On Linux and macOS:

getent hosts <address> || ping -c 1 <address>

On Windows, in PowerShell:

[System.Net.Dns]::GetHostAddresses('<address>') | Select-Object -ExpandProperty IPAddressToString

Either check always succeeds for this machine's own hostname, so it proves nothing about the other computers: each of them must resolve the name to the Docker host's LAN address.

If the name does not resolve, STOP - HUMAN REQUIRED. Editing /etc/hosts needs root. Ask the human to map the name to the Docker host, and give them the command for their platform. The IP in it is 127.0.0.1 when they run it on the Docker host itself, and the Docker host's own IP address when they run it on another computer. A hosts entry is a last resort for one workstation: DNS is the answer for anyone else who will use this deployment.

echo "<ip of the Docker host> <address>" | sudo tee -a /etc/hosts

On Windows, in an Administrator PowerShell session:

Add-Content -Path $env:SystemRoot\System32\drivers\etc\hosts -Value "`n<ip of the Docker host> <address>"

Tell them a hosts entry fixes only the machine it is run on, and that Pulse's own assistant needs no entry at all, because Gage__SelfAddress has it call Pulse on localhost. If colleagues will reach this deployment from their own computers, the name needs to resolve in DNS for all of them.

STOP - HUMAN REQUIRED. Only another computer can show that it reaches this deployment. Ask the human to open https://<address>:4542 from one of the other computers and tell you what they see. It should show Pulse's sign-in, with a certificate warning until Phase 6. Wait for the answer before Phase 6. If the page does not load, the usual cause is the Docker host's firewall: the HTTPS ports 4512, 4522, 4532, 4542, 4552, 4562 and 4572 must be reachable from them.

Verify: the address resolves, the human has confirmed that Pulse's sign-in loads from another computer, the CA endpoint answers, and the authorization_endpoint above names the address. This prints the status and size and not the certificate:

curl -sk -o /dev/null -w '%{http_code} %{size_download}\n' https://<address>:4542/api/ca

Expect HTTP 200 and about 1.4 KB (around 1400 bytes), served as application/x-x509-ca-cert.


Phase 6 - Trust the Pulse certificate authority#

Components obtain their own certificates from Pulse automatically. Nothing to do for them. The human's machine needs Pulse's root CA so browsers stop warning.

cd ./timebase && curl -k https://localhost:4542/api/ca -o pulse-ca.cer && ls -l pulse-ca.cer

STOP - HUMAN REQUIRED. Installing a root CA needs elevated rights.

Resolve the working directory first, and use the result everywhere below in place of <workdir>:

cd ./timebase && pwd

On Windows the human needs the C:\... form of the path, not Git Bash's /c/...: run pwd -W in place of pwd (it prints C:/...), and write that with backslashes. Use that form wherever a later phase says to resolve the working directory as in this one.

Tell the human the full path, then give them the command for their platform with the cd already in it - an elevated shell does not inherit your working directory, so a bare relative path will not find the file:

  • macOS: cd <workdir> && sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain pulse-ca.cer
  • Debian/Ubuntu: cd <workdir> && sudo cp pulse-ca.cer /usr/local/share/ca-certificates/ && sudo update-ca-certificates
  • RHEL/CentOS: cd <workdir> && sudo cp pulse-ca.cer /etc/pki/ca-trust/source/anchors/ && sudo update-ca-trust
  • Windows (Administrator PowerShell): cd <workdir>; Import-Certificate -FilePath pulse-ca.cer -CertStoreLocation Cert:\LocalMachine\Root

Tell them they must restart their browser completely afterwards. Browsers read the trust store at startup and cache validation results, so a reload will not pick up a new root CA. On Windows, closing every window is not enough: Edge and Chrome keep running in the background, and so keep the trust they read when they started. Have them type edge://restart in the address bar (in Chrome, chrome://restart), which restarts the browser and reopens their tabs.

Verify: the human confirms, on the Docker host, where the CA was just installed, that https://<address>:4542 loads without a certificate warning.

Only Pulse is running, and only Pulse is trusted, and that is expected. The other six start in Phase 7. Each serves a self-signed certificate until it holds its client secret, so its page still warns, and Explorer cannot reach the Historian over TLS, until its secret is saved a few seconds later. Do not treat that as a fault and do not try to fix it here.

Keep -k on every curl here, even once the root CA is trusted. Windows' own curl.exe also checks revocation, and Pulse-signed certificates carry no revocation address, so without -k it fails with CRYPT_E_NO_REVOCATION_CHECK (--ssl-no-revoke avoids that). That is not a fault, and browsers and OpenSSL are not affected.

If <address> is not localhost, every other computer that will open Timebase needs the same root CA, or its browser warns on every page. They can download it from https://<address>:4542/api/ca and install it with the command for their platform above. That is a next step for the human, not something to wait for now.

Loading that page presents a sign-in, so give them the credentials now rather than letting them guess - they may well try to sign in while they are there:

Username Administrator
Password timebase

Case-sensitive: Administrator, not administrator. This is the first-run account and is identical on every installation, so tell them to change the password as soon as they are in. They will be asked to do this again at the start of Phase 7.


Phase 7 - Give each component its client secret#

Pulse seeded one OAuth client per component on first start. Do not create any clients - they exist. What is missing is the secret: Pulse generates a random one per install and never prints it, while each component ships with an empty one. The token exchange fails until the secret is copied across, and nothing recovers on its own.

You cannot read a secret and must not handle one, so the human carries them: all six in one paste, through a small helper they run in a terminal of their own, or one at a time into each component's own setup form. Either way the component saves its own secret and is signed in and trusted within seconds, with no restart.

Only Pulse is running at this point, so the human finishes everything Pulse needs first, and you start the other six afterwards. They then find the finished configuration the first time they start, and each comes up showing its setup form.

The human's part in Pulse#

STOP - HUMAN REQUIRED. Client secrets are only retrievable through the Pulse interface; there is no public API for reading them. This is the human's first sign-in, so give them the credentials rather than assuming they have them. Ask them to:

  1. Open https://<address>:4542 and sign in as Administrator with the password timebase - the first-run account, case-sensitive, identical on every installation.
  2. Change that password immediately, from the user administration screen, before going further. It controls the identity provider, the certificate authority and the AI gateway.
  3. Only if <address> is not localhost: open Clients and add a sign-in address to each of the six clients in the table below. Choose the client, then Edit, add one line to Redirect URIs, and save. Keep the https://localhost:... line that is already there.
  4. Only if they chose a provider in step 1c: open Gage, then Providers, and follow The AI provider below.
Client Add this to Redirect URIs
Historian https://<address>:4512/callback
Collector https://<address>:4522/callback
Explorer https://<address>:4532/callback
Atlas https://<address>:4552/callback
Connect https://<address>:4562/callback
Bridge https://<address>:4572/callback

Pulse accepts a sign-in only for an address registered on the client, and no setting can register one for you. Without these, signing in to a component at <address> fails after the Pulse sign-in, on a page reading Client 'Explorer' does not support the specified redirect Uri, naming the client that is missing its address.

While they work, pull the other six images, so that starting them is quick. Tell the human their steps first, then run this before you wait for them. It pulls one image at a time, which also avoids a Windows credential-helper failure with parallel pulls. A failure here is not fatal: starting the stack below pulls again, and reports any real problem.

cd ./timebase && for s in historian explorer atlas connect bridge collector; do docker compose pull $s; done

The AI provider#

Skip this if they chose to leave the assistant for later.

Anthropic. Pulse already has a provider named Anthropic: Provider Id anthropic, model claude, handle anthropic/claude, enabled and without a key. Ask them to open Gage, then Providers, select it, and under API key choose Set new key, paste the key and choose Set key. That is the whole step: entering a key never changes whether a provider is enabled.

Another provider. Ask them to open Gage, then Providers, and choose Add provider. In the New provider drawer they fill in:

Field What to enter
Provider Id A short lower-case name of their own, such as openai. It becomes the first half of the model's handle and cannot be changed later
Display Name A label for people, such as OpenAI
Wire OpenAI for OpenAI, for Google Gemini's OpenAI-compatible address and for servers such as Ollama: the wire is the shape of the requests, not the company. Anthropic only for Anthropic's own API
Endpoint The provider's base address, such as https://api.openai.com
Upstream Path Blank, unless the provider serves requests at a non-standard path: Configuring Gage LLM providers and models has worked examples
API Key The provider's key, or blank for a server that needs none
Logical Id Under Logical models: a short lower-case name for the model, such as smart. The handle is the Provider Id and this, joined by a slash: openai/smart
Real Model Id The model name the provider expects, such as gpt-4o
Default Model Turn this on. See below

Turn on Default Model, or the assistant keeps using the seeded model. Until a default is marked, Pulse uses the alphabetically first handle in the catalogue. anthropic/claude sorts ahead of almost anything and has no key, so every question would fail against it. Marking the new model moves the default for every assistant in the installation.

A model on the Docker host itself. Pulse runs in a container, where localhost is the container and not the host. Use an address the container can reach: http://host.docker.internal:<port> on Docker Desktop (Windows and macOS), or the host's own IP address on Linux. The server must also listen on more than its own loopback address: for Ollama, set OLLAMA_HOST=0.0.0.0. A provider in the cloud needs only outbound internet access from the Docker host.

Confirm it works before they leave Pulse. The Ask Gage button in Pulse's header opens the assistant, for administrators only. Ask it anything: an answer means the key works, and an authentication error means it does not. Nothing else in this runbook can test a key, because nothing is asked of the provider until a question is.

Start the other six#

Once the human says the steps above are done, start the rest. Pulse is already running and is left alone:

cd ./timebase && docker compose -f docker-compose.yml up --detach --pull always

This pulls anything not already pulled, which may take several minutes.

On Windows, pulling several images at once can fail with error getting credentials ... The paging file is too small for this operation to complete from Docker Desktop's credential helper. It is transient. Pull one image at a time (docker compose pull historian, then the next) and run the up again without --pull always.

Verify: all seven component services report running.

cd ./timebase && docker compose ps --format '{{.Name}} {{.State}}'

Each of the six now waits for its secret, which is the next step.

Give each component its secret#

Every component serves an anonymous status check at /app/setup/pulse, and while Pulse rejects its client the component's own interface shows a Pulse setup form in place of the sign-in. That form is the only place a secret is ever saved, by whichever route the human takes below: the component checks the secret with Pulse before it keeps it, writes it into its own configuration, and re-checks Pulse immediately. Nothing is copied through your shell, so you never handle one.

Before anyone starts, read the status of all six so you know what "done" looks like:

for p in 4512 4522 4532 4552 4562 4572; do printf "%s: " $p; curl -sk https://localhost:$p/app/setup/pulse; echo; done

Each line reads "state":"rejected" with "secretConfigured":false and "canApply":true before the secret is entered, and "state":"ok" with "secretConfigured":true afterwards.

If a running component answers with an error page instead of that status, it has no setup form: its image is older than 2.0, for example a latest image from an earlier release still on the host. Stop and report it, and check TIMEBASE_TAG in .env and that the start pulled with --pull always. Do not write the secret into its settings.config yourself - Rule 9.

Do not ask them to rotate a secret: Pulse generated every one when it first started, so they are all new and there is nothing to replace. Do not ask for a secret for the Pulse client: it is a loopback client for Pulse's own assistant, Pulse reads its secret from its own store, and nothing is copied anywhere.

Ask the human, in these words, and wait for the answer:

Each of the six components needs its own secret from Pulse. I can give you a small helper to run in a terminal of your own: you copy all six from Pulse in one go, paste them once, and it gives each one to the right component. Or you can copy and paste them one at a time, each into that component's own page. Which would you like?

Assume the helper if they have no preference. Both routes end in the same place, and the second is always there to fall back on.

Route 1 - the helper, one paste#

The helper is a short Python script. It asks for the secrets when the human runs it, so it holds none, and it writes none anywhere: not to a file, not to the environment, not to its output. It gives each secret to that component's own setup form on this machine, waits for any component that is still starting, and finishes by asking each component whether Pulse accepts it. It only fills a form that is still empty, so running it again is safe.

Write it to apply-secrets.py in ./timebase, exactly as below. It is the same on every installation and there is nothing to fill in:

"""Give each Timebase component its Pulse client secret, in one paste.

Run this yourself, in a terminal of your own, on the machine that runs Docker. It asks you to
paste the line that Pulse's Copy all button puts on the clipboard, then hands each secret to the
matching component's own setup form: the same form that component's web page shows. Each
component checks its secret with Pulse before it keeps it.

This script never writes a secret anywhere: not to a file, not to an environment variable, not
to a log, and not to the screen. The secrets exist in this process's memory until it exits, and
they are sent to one place only: the six setup forms on this machine (localhost).
"""

import http.client
import json
import os
import ssl
import sys
import time
import urllib.error
import urllib.request

# The client id (the name Pulse's Copy all uses) and the HTTPS port of each component.
COMPONENTS = [
    ("Historian", 4512),
    ("Collector", 4522),
    ("Explorer", 4532),
    ("Atlas", 4552),
    ("Connect", 4562),
    ("Bridge", 4572),
]

GIVE_UP_AFTER = 300     # seconds to wait for the components to come up and take their secret
CHECK_FOR = 90          # seconds to wait for every component to report ok afterwards
REFUSALS_ALLOWED = 5    # Pulse can refuse a good secret for a few seconds after a start

BANNER = """\
Timebase: give each component its Pulse client secret

This window waits for you. Do these two things, in this order:

  1. In Pulse, open Overview. In the card "Connect your Timebase components",
     choose Copy all (open the card first if it is closed).
  2. Come back to this window, paste, and press Enter.

Nothing you paste is shown. The count below only tells you that it arrived,
and it should read 6 secrets before you press Enter.
"""

# A component serves a self-signed certificate until it holds its secret, so its certificate
# cannot be checked yet. Every request goes to this machine only, and never through a proxy.
_TLS = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
_TLS.check_hostname = False
_TLS.verify_mode = ssl.CERT_NONE
_OPENER = urllib.request.build_opener(
    urllib.request.ProxyHandler({}), urllib.request.HTTPSHandler(context=_TLS))


def call(method, port, body=None):
    """Ask, or tell, one component's setup form. Returns (http status, parsed json).

    The status is None when nothing answered. The json is None when the answer was not JSON."""
    request = urllib.request.Request(
        "https://localhost:%d/app/setup/pulse" % port,
        data=None if body is None else json.dumps(body).encode("utf-8"),
        method=method,
        headers={"Content-Type": "application/json"})
    try:
        with _OPENER.open(request, timeout=20) as response:
            status, raw = response.status, response.read()
    except urllib.error.HTTPError as error:
        status, raw = error.code, error.read()
    except (urllib.error.URLError, http.client.HTTPException, OSError):
        return None, None
    try:
        return status, json.loads(raw.decode("utf-8") or "null")
    except ValueError:
        return status, None


def read_hidden(prompt):
    """Read one line without showing it. A running count shows that a paste has arrived."""
    typed = []

    def show():
        text = "".join(typed)
        sys.stdout.write("\r%s%d characters, %d secrets   " % (prompt, len(text), text.count(" - ")))
        sys.stdout.flush()

    show()
    if os.name == "nt":
        import msvcrt
        while True:
            ch = msvcrt.getwch()
            if ch in ("\r", "\n"):
                break
            if ch == "\x03":
                raise KeyboardInterrupt
            if ch in ("\x00", "\xe0"):      # a special key: its second code follows
                msvcrt.getwch()
                continue
            if ch in ("\b", "\x7f"):
                if typed:
                    typed.pop()
            elif ch >= " ":
                typed.append(ch)
            show()
    else:
        import termios
        import tty
        fd = sys.stdin.fileno()
        saved = termios.tcgetattr(fd)
        try:
            tty.setcbreak(fd)
            finished = False
            while not finished:
                chunk = os.read(fd, 4096)
                if not chunk:
                    break
                for ch in chunk.decode("utf-8", "ignore"):
                    if ch in ("\r", "\n"):
                        finished = True
                        break
                    if ch in ("\b", "\x7f"):
                        if typed:
                            typed.pop()
                    elif ch >= " ":
                        typed.append(ch)
                show()
        finally:
            termios.tcsetattr(fd, termios.TCSADRAIN, saved)
    sys.stdout.write("\n")
    return "".join(typed)


def parse(line):
    """Turn 'Historian - <secret> / Collector - <secret> / ...' into {client id: secret}."""
    found = {}
    for entry in line.strip().split(" / "):
        name, divider, secret = entry.partition(" - ")
        name, secret = name.strip(), secret.strip()
        if divider and name and secret:
            found[name] = secret
    return found


def give_secrets(secrets):
    """Hand each secret to its component's form once that component is up. Returns {name: (result, note)}."""
    ports = dict(COMPONENTS)
    pending = [name for name, _ in COMPONENTS if name in secrets]
    results = {}
    refusals = {}
    started = last_notice = time.time()

    def finish(name, result, note=""):
        results[name] = (result, note)
        pending.remove(name)
        print("  %-10s %s" % (name, {"applied": "applied", "kept": "kept: " + note,
                                     "failed": "NOT DONE: " + note}[result]))

    while pending and time.time() - started < GIVE_UP_AFTER:
        for name in list(pending):
            status, form = call("GET", ports[name])
            if status is None:
                continue                                    # not listening yet
            if form is None:
                finish(name, "failed", "it has no setup form (its image is older than 2.0)")
            elif form.get("clientId") not in (None, name):
                finish(name, "failed", "port %d answered as client '%s', not '%s'"
                       % (ports[name], form.get("clientId"), name))
            elif form.get("state") == "disabled":
                finish(name, "kept", "its sign-in is switched off, so it takes no secret")
            elif form.get("overriddenBy"):
                finish(name, "failed", "its secret is set by %s, which this form cannot change"
                       % form["overriddenBy"])
            elif form.get("secretConfigured"):
                if form.get("state") == "ok":
                    finish(name, "kept", "it already holds its secret, so it was left as it is")
                else:
                    finish(name, "failed", "it holds a secret that Pulse no longer accepts, "
                                           "and the form cannot replace it")
            elif form.get("canApply"):
                code, reply = call("POST", ports[name], {"clientSecret": secrets[name]})
                reply = reply if isinstance(reply, dict) else {}
                if code == 200 and reply.get("outcome") == "applied":
                    finish(name, "applied")
                elif reply.get("outcome") == "refused":
                    refusals[name] = refusals.get(name, 0) + 1
                    if refusals[name] >= REFUSALS_ALLOWED:
                        finish(name, "failed", "Pulse refused that secret (%s)"
                               % (reply.get("message") or "no reason given"))
                elif reply.get("outcome") == "invalid":
                    finish(name, "failed", "the form would not take it (%s)"
                           % (reply.get("message") or "no reason given"))
                # Anything else: look again on the next pass.
            # Otherwise the component cannot reach Pulse yet: wait.
        if pending:
            if time.time() - last_notice >= 10:
                print("  still waiting for: %s" % ", ".join(pending))
                last_notice = time.time()
            time.sleep(2)
    for name in list(pending):
        finish(name, "failed", "it did not come up and take its secret within %d seconds" % GIVE_UP_AFTER)
    return results


def confirm(names):
    """Wait until each component's own check with Pulse says ok. Returns the names that did not."""
    ports = dict(COMPONENTS)
    waiting = list(names)
    started = time.time()
    while waiting and time.time() - started < CHECK_FOR:
        for name in list(waiting):
            _, form = call("GET", ports[name])
            if isinstance(form, dict) and form.get("state") == "ok" and form.get("secretConfigured"):
                print("  %-10s ok" % name)
                waiting.remove(name)
        if waiting:
            time.sleep(2)
    for name in waiting:
        print("  %-10s NOT OK: Pulse does not accept it yet" % name)
    return waiting


def main():
    if not (sys.stdin.isatty() and sys.stdout.isatty()):
        print("Run this in a terminal of your own, not through an agent or a pipe.")
        print("It asks you to paste secrets, and nothing else should be able to read them.")
        return 2

    print(BANNER)
    secrets = parse(read_hidden("Paste here, then press Enter: "))
    present = [name for name, _ in COMPONENTS if name in secrets]
    missing = [name for name, _ in COMPONENTS if name not in secrets]
    if not present:
        print("That was not what Copy all produces. It looks like:  Historian - ... / Collector - ... / ...")
        print("Choose Copy all in Pulse again, then run this again.")
        return 1
    print("Got %d secrets: %s." % (len(present), ", ".join(present)))
    if missing:
        print("Not in the paste: %s. Give those through their own setup forms." % ", ".join(missing))

    print("\nGiving each component its secret:")
    results = give_secrets(secrets)
    problems = [name for name in missing]
    problems += [name for name, (result, _) in results.items() if result == "failed"]

    usable = [name for name, (result, _) in results.items() if result in ("applied", "kept")]
    print("\nAsking each component whether Pulse accepts its secret:")
    problems += confirm(usable)

    print()
    if problems:
        print("Not finished: %s." % ", ".join(dict.fromkeys(problems)))
        print("For each, open that component's own page and paste its secret into the setup form instead.")
        print("Tell the agent what is printed above. It will not ask you for any secret.")
        return 1
    print("All six components hold their secret, and Pulse accepts each one.")
    print("Tell the agent this finished. Pulse's Overview should now read 6 of 6 connected.")
    print("Your clipboard still holds the secrets: copy something else over it. If clipboard history")
    print("is on (Windows: Win+V), clear it there as well.")
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        print("\nStopped. Any component listed above as applied keeps its secret.")
        sys.exit(130)

Verify that it is valid Python. This only parses it; nothing runs. Use python in place of python3 where Phase 1 found only python:

cd ./timebase && python3 -c "import ast; ast.parse(open('apply-secrets.py').read()); print('syntax ok')"

STOP - HUMAN REQUIRED. Resolve the working directory as in Phase 6 and give the human these steps in this order. The order matters: the clipboard holds one thing at a time, so they copy and run the command first, and copy the secrets last.

  1. Run the helper. Copy the command for their platform, paste it into a terminal of their own and press Enter: PowerShell on Windows (a PowerShell tab in Windows Terminal is fine), not Command Prompt, not Git Bash, and not this session. The window then waits for them.
    • Windows: cd <workdir>; python apply-secrets.py
    • macOS and Linux: cd <workdir> && python3 apply-secrets.py
  2. Copy the secrets. Only now, in Pulse, open Overview. In the card Connect your Timebase components, choose Copy all under OAuth clients (open the card first if it is closed). That puts one line holding all six secrets on the clipboard. The card's own text also says to paste each secret into a configuration file and restart the component. They should not: the helper does it through each component's own form.
  3. Paste and press Enter. In the helper's window, paste and press Enter. If Ctrl+V does nothing, right-click in the window to paste. Nothing they paste is shown, only a count, which should read 6 secrets before they press Enter.
  4. Wait for it to finish. It lists each component as it is done, and ends by saying that all six hold their secret and Pulse accepts each one. Pulse's Overview then reads 6 of 6 connected. Ask them to tell you when it has finished, and to copy something else afterwards so the secrets do not stay on the clipboard (if clipboard history is on, in Windows Win+V, they clear it there too).

Do not ask them to read you anything they pasted. The helper prints no secret, so if it reports a problem they can read you what it says. If it reports a component as not done, or they would rather not use it, take Route 2 for that component.

Verify with the status loop above: every line must read "state":"ok" with "secretConfigured":true and "canApply":false.

Route 2 - the setup forms, one at a time#

For each of the six components in the table, ask the human to:

  1. In Pulse, open Clients, choose the client, reveal the secret and copy it.
  2. Open the component's address from the Phase 9 table, paste the secret into the form and save.
Component Client Id
Historian Historian
Collector Collector
Explorer Explorer
Atlas Atlas
Connect Connect
Bridge Bridge

The component writes the secret into its own configuration and re-checks Pulse immediately, so it needs no restart.

If a component's log still prints Invalid client secret provided more than a minute after its form reported ok, restart that one: docker compose restart <service>.

Certificates follow the secret#

A component requests its Pulse-signed certificate only once its client id and secret are both set, so Phase 7 is also what moves the six components off their self-signed certificates. Pulse logs RootCA signed leaf certificate: CN=Timebase <Component> Pulse-Signed for each one within a few seconds of its form reporting ok.

Verify that every component now presents a certificate issued by the Pulse root CA:

for p in 4512 4522 4532 4552 4562 4572; do printf "%s: " $p; echo | openssl s_client -connect localhost:$p -servername localhost 2>/dev/null | openssl x509 -noout -issuer; done

Every line must name Timebase Pulse Root CA. A line still reading Self-Signed means that component's secret has not been applied, or it has not yet reconnected to Pulse; wait a minute and re-run before doing anything else.

Expect errors until the secrets are in, and read nothing into them. A component that does not yet hold its secret logs failed sign-ins to Pulse, and just after a start it can log The SSL connection could not be established until it has fetched Pulse's certificate authority. Atlas and Bridge each log one Gage model discovery ... no bearer token warning that they recover from within a second. Judge a component by the checks above, and by a log window taken once its secret is in and a minute has passed.

If a component does not say ok#

  • "state":"rejected" with "secretConfigured":false and "canApply":true: the component still has no secret. Its form has not been filled in, or the save did not go through: ask the human to paste that component's secret again. The form checks the secret with Pulse before it saves anything, so a refused secret is simply not saved. If they used the helper, they can run it again: it only fills the forms that are still empty. Or take Route 2 for that component.
  • "state":"rejected" with "secretConfigured":true: a secret is saved and Pulse no longer accepts it, which happens if the secret was rotated in Pulse afterwards. The form fills an empty secret only and cannot replace one. Stop and report it: do not write the secret into the file yourself - Rule 9.
  • "state":"unreachable": the component cannot reach Pulse at all. Check that pulse is running and read the component's log.

Nothing is collecting yet, and that is correct#

A running stack is an empty stack. Every example collector and every example connector ships disabled, so a fresh installation stores nothing until somebody turns one on. Their names all begin example-, which is how you tell a shipped sample from something a human made.

Do not treat an empty Historian as a fault, and do not enable anything yourself. Say so in Phase 9 instead, and point the human at the Collector interface, where switching on example-simulator gives them data to look at within a minute.

7a. Check the data connections (change them only if the Historian refuses them)#

The secret above lets a component authenticate to Pulse. Two components also keep a data connection of their own: the Collector's link to the Historian, and Explorer's Historian and Atlas sources. Each has its own authentication setting. A connection that names no client of its own authenticates as its component and needs nothing more. But a shipped connection that names a client and is switched off counts as the connection's own credentials, so it presents none. Check rather than assume.

Wait two minutes after the last secret went in, so that Explorer has polled the Historian several times with its secret in place, then read a clean window:

cd ./timebase && docker compose logs historian --since 1m | grep -c "Authorization header ABSENT"

A count of 0 means there is nothing to do here: go on to 7b. Note that grep -c exits with status 1 when the count is 0: that is the pass, not an error. Do not give those connections a client of their own. It is not needed, and a mistyped secret would break what already works.

A count above 0 is 401 Unauthorized with Authorization header ABSENT in the Historian's log: no token was attached at all, and Explorer's source reports that it cannot connect. This is not the audience problem in 7b - granting the audience does nothing for it. Do not confuse the two.

STOP - HUMAN REQUIRED. The fix is a setting in each component's own interface, so the human makes it, not you: Rule 9. Both screens need an administrator, which the Administrator account is; if Edit is greyed out, or a save is refused, the account they are signed in with is not one. Both apply on save, so nothing needs restarting. The secrets are the Collector's and Explorer's from Phase 7; they copy each again from Clients in Pulse: select the client, reveal the secret, copy it. Ask them to make the same six settings in each place:

Setting Value
Auth Enabled in the Collector, Enabled in Explorer on
Client ID Collector, or Explorer
Client Secret that component's secret
Identity Provider > Host pulse
Identity Provider > Port 4542
Identity Provider > Use TLS on
  • Collector, https://<address>:4522: in the sidebar open Historians, choose Edit on the Historian row (it shows when the pointer is over the row), open its Authentication tab, make the settings and save.
  • Explorer, https://<address>:4532: in the sidebar open Config > Sources, choose Edit on the Historian source, open its Authentication tab and then the Authentication row, switch Enabled on (the other settings appear once it is on), make the settings and save. Do the same for any other source they will use.

Verify over a clean window, not a line count. Wait a minute after they save, then:

cd ./timebase && docker compose logs historian --since 1m | grep -c "Authorization header ABSENT"

A count of 0 is the pass condition, and exits 1, as above. Use --since, never --tail, when checking whether something is now fixed - a tail can include lines from before the change and make a working fix look broken.


7b. Check for authentication failures (act only if any appear)#

Wait until every component holds its secret and two minutes have passed, so the noise described in Phase 7 is behind you, then read the last minute:

cd ./timebase && for c in historian explorer atlas connect bridge collector; do \
  echo "== $c"; docker compose logs "$c" --since 1m \
    | grep -iE "invalid_client|invalid_request|InvalidClientSecret|Invalid client secret|401|token request failed" \
    || echo "  no auth errors"; done

The pattern covers both shapes of failure: a 401, and the HTTP 400 that a component logs for a missing or wrong secret, invalid_request with Invalid client secret provided. Do not shorten it. A bare 401 can also match a timestamp ending .401, so read a match before acting.

Verify: no authentication failures in any component's log.

If a component authenticates to Pulse but is rejected by another component, that is an audience problem, not a credentials one: to let X call Y, add Y to X's audiences in Pulse, then restart X. Explorer and Collector both need Historian.


Phase 8 - Final checks#

STOP - HUMAN REQUIRED. One question, then wait: have they changed the administrator password? They were asked to in Phase 7. If it is still timebase, it must be changed now, from the user administration screen in Pulse, before the instance is reachable by anyone else. It is their decision: if they decline, for example on a test machine, do not press and do not stop. Carry on, and say plainly in the Phase 9 report that the default password is still in place, so anyone who can reach the instance can sign in as Administrator. Nothing else is required of them here: user accounts and usage limits are next steps, offered in Phase 9.

Verify: the catalogue every component received names the model the human chose. A component logs it once Pulse has pushed it, which is after that component's client secret is saved and not when it starts. That can take up to about two minutes, because the component retries on a growing back-off, so if the command finds nothing, wait and run it again. No secret is involved:

cd ./timebase && docker compose logs atlas | grep "Gage model catalog pinned" | tail -1

The line ends N model(s), default '<handle>'. With the seeded provider only, which is also what Later leaves, it reads 1 model(s), default 'anthropic/claude'. With a provider of their own it must name that provider's handle, such as default 'openai/smart': any other default means Default Model was not turned on in Phase 7. This does not prove the key. The catalogue itself, GET /api/gage/models, answers only to an authenticated caller, so a bare curl gets 401.


Phase 9 - Report#

This is the one message the human reads at the end, so write it for them, not for an engineer. Plain language, short, and nothing they would have to look up: no container names, image tags, setting names, commands or log lines, and no account of the checks you ran. If every check passed, do not mention checks at all.

It has two parts, in this order.

What was done. A basic summary in a few sentences: Timebase 2.0 is installed and running on this machine, and its seven components are up, connected to each other and secured. Say plainly that nothing is collecting data yet and that this is expected, because the sample data sources that come with Timebase stay switched off until someone turns one on. Say who can sign in: for now only the Administrator account, with the password they chose. Say where the AI assistant stands: set up with the provider they chose, or left for later. Say only what you saw: if you did not check something, do not claim it works, and the assistant is only known to work if they asked it a question in Phase 7. Then give them the address of each component, so they can open them, with <address> replaced by localhost or by the address from step 1b:

Component URL
Historian https://<address>:4512
Collector https://<address>:4522
Explorer https://<address>:4532
Pulse https://<address>:4542
Atlas https://<address>:4552
Connect https://<address>:4562
Bridge https://<address>:4572

If a step did not finish, or a check did not give the expected result, say so here, in one sentence they can act on: what is not working and what that means for them. Do not hide it, and do not explain it in developer terms.

Next steps. This is the part they will act on, so give it its own heading and make it stand out. List the Next steps below, each as a sentence or two in your own plain words saying what it gets them rather than how it is done. Offer to help with any of them, and do not start one unless they ask.


Next steps#

None of these is part of this runbook: each is the human's decision. List the ones that apply in your Phase 9 report, in plain words, and do none of them unless the human asks. A step marked If is listed only when its condition holds. The detail under each is for you, in case they ask for help with it.

  1. See data arrive. Nothing is collecting yet. In the Collector interface, switching on example-simulator shows data arriving within a minute, which makes it the quickest way to see Timebase work end to end. Every example collector and connector begins example- and ships disabled on purpose.
  2. Add the people who will use it. Until they do, only the Administrator account can sign in. In Pulse, Users > Add User: Id (an email works), first name, last name, password. Is Administrator grants admin rights; there are two levels, Admin and Standard.
  3. Switch on the AI assistant. If they left it for later. It answers nothing until a provider has a key. In Pulse, under Gage and then Providers, they add the key as Phase 7, The AI provider, describes. They enter it themselves, as before, and the assistant is ready to answer once it is saved.
  4. Set how much the AI assistant may be used. If the assistant is set up, which for a human who left it for later means after the previous step. Limits are imposed by default and should be adjusted before many people use it. In Pulse, Policy > User > edit [default] > Rate & budget limits. Set both Current session and the weekly limit; -1 means unlimited. Choose an Enforcement setting: Soft records overage and never refuses, Throttle returns HTTP 429 with Retry-After, Block rejects outright. Start with Soft on a new deployment. Repeat under Client for components calling on their own behalf.
  5. Use certificates from their own CA - recommended beyond a trial. Pulse's certificate authority keeps the stack working, but every machine that opens an interface has to trust its root, as Phase 6 did for this one. Certificates from the organization's own CA are trusted already, and their renewal stays with whoever runs that CA. It is not a drop-in swap: the certificate must carry localhost and every Compose service name, and each component then trusts the CA through a bundle the human supplies. The Bring your own certificate section of Certificates and trust across Timebase walks through it, with a Compose override for this stack.
  6. Other computers. If they chose this computer only and colleagues should now use it, the address changes in one place and Pulse learns the new sign-in addresses: steps 1b and 2b, Phase 5 and Phase 7. If they chose other computers, each of those computers must install Pulse's root CA once, or its browser warns on every page: Phase 6, from https://<address>:4542/api/ca.
  7. Back up the volumes. Every setting, credential and dataset lives in this stack's named volumes. Disaster Recovery, under Best Practice: Docker Volumes, covers what to keep.
  8. Work on Atlas from a coding assistant. If they use one, such as Claude Code. Atlas publishes an MCP server at https://<address>:4552/mcp. A component token gets its read tools only; when the human signs in through the assistant with the Administrator account, it also gets the tools that build and compile models, and every change is attributed to them. The human registers a client for the assistant in Pulse, under Clients: grants authorization_code and refresh_token, scopes openid, profile and offline_access, audience Atlas, and redirect URI http://localhost:<port>/callback on a port nothing else on their machine uses. You may write the assistant's configuration from the client id they give you and hand them the command that adds the secret, but never handle the secret yourself, and the sign-in happens in their browser, not yours. On this release the assistant also needs Pulse's discovery document named explicitly: https://<address>:4542/auth/.well-known/openid-configuration. The field values, the Claude Code command and the checks are in Atlas MCP Server, under Gage, the AI harness.

Known unknowns#

One point is not settled in the documentation. Do not assert it as fact.

  1. Whether a Gage call must clear one policy layer or both. One reference says the layer is chosen by caller type; another says both are always enforced.
esc
↑↓ navigate · ↵ open · ⌘K anywhere