# Timothy Part 24: An Orange T and a One-Line Install

> Source: https://www.sumonselim.com/timothy-part24-an-orange-t-and-a-one-line-install
> Author: Muhammad Sumon Molla Selim
> Published: 2026-08-04
> Tag: Timothy
> Summary: Two commits landed twenty-five minutes apart on the same morning: one gave Timothy a face by rotating the brand accent from purple to a tabby-orange T mark, and one made it installable without cloning the repo, through a release pipeline, a prebuilt-image compose file, and a magic-link sign-in.

Two changes shipped twenty-five minutes apart on the same August morning. The first gave Timothy a face. The second made it installable without a Go toolchain. Neither depends on the other. They just happened to get done in the same sitting, and together they mark the line between a project that runs on my laptop and one someone else can actually pull down and run on theirs.

## Why the rebrand was one CSS token

For its first month Timothy had no mark of its own. The favicon was a generic purple "bolt" in `#863bff` and `#7e14ff`, a multi-kilobyte SVG full of blurred ellipses and filter definitions. The sidebar used a tiny blue-to-violet gradient dot. The brand accent, the CSS variable that drives buttons, focus rings, and highlights, was a purple at `oklch(0.48 0.19 300)`. None of it had anything to do with the name. Timothy is my orange cat, and the identity never reflected that.

The new mark is a white slab **T** on a tabby-orange gradient tile, `#ff9d4d` at the top-left fading to `#f4700e` at the bottom-right, with `rx="10"` rounded corners. Three SVG masters live in `assets/brand/`: `timothy-mark.svg` (rounded), `timothy-mark-square.svg` (sharp corners, for contexts where rounding clips), and `timothy-glyph.svg` (flat `#f4700e`, no gradient, no tile, for single-color contexts that can't render a gradient). The React component that renders it in the sidebar is new:

```tsx
// web/src/components/BrandMark.tsx
// BrandMark: the orange-tile "T" brand mark, inlined as SVG (not an
// <img> reference to /favicon.svg) so it stays crisp and renders the
// same regardless of theme. Same gradient + path as public/favicon.svg.
export function BrandMark({ className }: { className?: string }) {
```

Inlined, not referenced as an `<img>`, so it inherits the document's color and scaling and never fetches a second asset.

The deeper change is the CSS. The brand accent moved from purple to orange by rotating the hue of the `--brand` token from 300 to 46:

```css
/* web/src/index.css */
--brand: oklch(0.66 0.19 46);
```

That's the whole rebrand at the token layer. Lightness and chroma were tuned per theme (orange goes muddy at purple's darkness, so the light-theme `--brand` lightness moved from 0.48 to 0.66, and the soft variants shifted from hue 300 to 46 and 55), but the hue rotation is the load-bearing change. Eight token lines in `index.css`, plus the `BrandMark` component and the favicon swap, are the entire UI diff.

This was surgical on purpose. Months ago, before there was a mark to rebrand, the token system in `index.css` deliberately segregated `--brand` from shadcn's `--accent`. A comment in that file, predating this commit by weeks, explains the reasoning:

```css
/* web/src/index.css */
/* Brand accent: single orange hue, both themes. Separate from
   shadcn's --accent (a neutral hover surface, used all over ui/*)
   so re-theming this doesn't repaint every hover state. */
```

`--accent` is the neutral hover surface that dozens of shadcn components reach for. If the brand color had been threaded through `--accent`, changing the brand would have repainted every dropdown, every hover state, every selected row. Because the two were kept apart, rebranding to orange meant touching `--brand` and nothing else. The planning, not the commit, is why the diff is sixteen lines.

The explicit non-goal: chart and entity-graph **categorical palettes** stayed untouched. The commit message names this directly. The eight-color palette that [Part 23](https://www.sumonselim.com/articles/timothy-part23-hand-built-charts-and-a-faster-ci) introduced for the analytics charts, and that the entity graph also consumes, encodes the identity of a data series, not the identity of the product. Reusing those slots as brand carriers would create false associations: if "orange" means both "Timothy" and "the second series in the cost chart," the chart stops being readable. So `palette.ts` keeps its blue-leading order, and brand orange lives only where brand actually belongs.

The trade-off is honest. The gradient is now the brand, and a gradient is more expensive to reproduce than a flat color. It's duplicated across the favicon, the `BrandMark` component, and roughly a hundred kilobytes of committed PNG assets (the 1024px render alone is 67KB, the 512px GitHub-avatar size is 20KB). Flat-color contexts like favicons at 16px can't render a gradient cleanly, which is why `timothy-glyph.svg` exists as a flat `#f4700e` fallback. None of that is a problem; it's the cost of a gradient identity.

## Why install used to need a Go toolchain

Before this commit, the only way to run Timothy was to clone the repo and run `make up`. The source compose file has `build:` blocks on every service, which means Docker needs the Go and Node toolchains in the build image, plus a `HUGEICONS_TOKEN` build argument to fetch the paid icon set the web UI depends on. That's fine for development and correct for a single-author project, but it's a wall for anyone who just wants to try the thing.

The release pipeline is a new workflow that triggers only on tag pushes matching `v*`:

```yaml
# .github/workflows/release.yml
on:
  push:
    tags: ['v*']
```

It builds all eight service images in a matrix, multi-arch (`linux/amd64,linux/arm64`) via QEMU and buildx, and pushes them to GHCR under `github.repository_owner`:

```yaml
# .github/workflows/release.yml
tags: ghcr.io/${{ github.repository_owner }}/timothy-${{ matrix.name }}:${{ steps.version.outputs.value }}
```

Using `repository_owner` instead of a literal owner string means the images land under whatever user or org the repo belongs to. The CI badges in the README were updated from `SumonMSelim` to `timothy-agent` in the same commit, and the image references survived the rename without a code change.

One opinionated decision: there are no test jobs in the release workflow. The comment states it plainly:

```yaml
# .github/workflows/release.yml
# No test jobs here on purpose: releases are cut by tagging a commit
# on main that already went green through ci-ok. Re-running build/
# vet/test/lint against the same commit here would only slow the
# release down for no new signal.
```

The `ci.yml` workflow already runs build, vet, race-enabled tests, integration tests, lint, and image builds on every pull request, gated behind a single required `ci-ok` check that demands every job report exactly `success`. A release tag is cut from a commit on `main` that already passed that gate. Re-running the same suite against the same bytes in the release workflow would burn CI minutes and add latency without catching anything new. The release job's only responsibilities are assembling the deploy assets, substituting the `__TIMOTHY_TAG__` placeholder into the installer and env template, and detecting prerelease tags (a hyphen in the version, per semver, means prerelease):

```yaml
# .github/workflows/release.yml
case "${{ steps.version.outputs.value }}" in
  *-*) args+=(--prerelease) ;;
esac
```

Concurrency is set to one run per tag, with `cancel-in-progress: false`. A half-published multi-arch manifest, where amd64 pushed but arm64 failed, is worse than a slow release. Tags are immutable, so there's never a newer run that should preempt an in-flight one.

The web build has one wrinkle worth naming. The web image needs the `HUGEICONS_TOKEN` to fetch its icon set during build, but that token is paid and must never leak into a published image layer. It's passed as a BuildKit **secret**, not a build-arg or environment variable:

```yaml
# .github/workflows/release.yml
# web only: HugeIcons Pro registry auth, passed as a BuildKit
# secret so it never lands in an image layer (not as a
# build-arg or env, both of which persist into layer history).
secrets: ${{ matrix.secret == 'true' && format('hugeicons_token={0}', secrets.HUGEICONS_TOKEN) || '' }}
```

Build-args and env vars both persist into layer history, which is inspectable on a public registry. A BuildKit secret mounts into the build context, is used, and is gone. The matrix marks only `web` with `secret: "true"`, so the other seven services never see it.

## The prebuilt deploy and the installer

The release ships a second compose file, `deploy/release/docker-compose.yml`, that replaces every `build:` block with a pinned `image:` line and drops the `web-dev` profile entirely. No toolchain, no HugeIcons token, no build step. Services are pinned through a single variable:

```yaml
# deploy/release/docker-compose.yml
image: ghcr.io/timothy-agent/timothy-brain:${TIMOTHY_VERSION:?set TIMOTHY_VERSION in .env}
```

The `${TIMOTHY_VERSION:?...}` pattern is fail-fast: if the variable is unset, compose refuses to start rather than silently pulling a `:latest` and drifting. There is no `:latest` tag on any of these images.

The installer is a POSIX `sh` script, not bash, so it runs anywhere. It preflights for `docker`, the compose v2 plugin, `openssl`, and either `curl` or `wget`. On first run it generates three secrets locally with `openssl rand`: a postgres password, a master key, and an API token. It writes them into `.env` and tells the terminal it did not print them. That's true for the postgres password and master key. The API token it does print, once, at the end, because the API token is the sign-in link.

The **`TIMOTHY_MASTER_KEY`** is the root of trust for the encrypted secret store. Provider credentials and connector secrets are configured in the Settings UI, not in env, and this key is what decrypts them at runtime. Losing it makes every encrypted secret unrecoverable. The installer generates it with `openssl rand -base64 32` and never shows it again.

Two portability footguns are handled. First, BSD/macOS `sed` requires an extension argument to `-i`, where GNU `sed` does not. A wrapper function papers over it:

```sh
# deploy/release/install.sh
# Portable in-place sed edit (BSD/macOS requires an extension arg to -i).
sed_inplace() {
  sed -i.bak "$1" .env && rm -f .env.bak
}
```

Second, the Docker socket's group id differs between hosts. On Linux it's whatever gid the `docker` group has, detected with `stat -c '%g'`; on Docker Desktop (macOS) it's 0. The installer detects it and writes `DOCKER_SOCK_GID` into `.env`, and the compose file grants that group to the sandboxd container.

### The sandbox image the installer has to pull by hand

The one war story worth telling from the installer is the mission sandbox image. The `sandboxd` service holds the Docker socket so the brain doesn't have to. Mission workers run inside a general-purpose sandbox container, and the image for that container is passed to sandboxd as an env var, `MISSION_SANDBOX_IMAGE`, not as a compose service. It's referenced, not defined.

That creates a silent failure mode. `docker compose pull` only pulls images for services declared in the compose file. The sandbox image isn't one. So a fresh install with a configured `MISSION_SANDBOX_IMAGE` would start sandboxd, accept a mission request, and then fail to start the sandbox container because the image was never pulled. The installer closes the gap explicitly:

```sh
# deploy/release/install.sh
# compose pull only covers services; the mission sandbox image is an
# env var handed to sandboxd, so pull it explicitly or missions stay
# degraded until someone pulls it by hand.
sandbox_image=$(sed -n 's/^MISSION_SANDBOX_IMAGE=//p' .env | head -n1)
if [ -n "$sandbox_image" ]; then
  echo "Pulling mission sandbox image..."
  docker pull "$sandbox_image"
fi
```

Without this, missions silently degrade on first use. With it, the image is present before the stack starts.

## Why sandboxd's internal API has no auth

The strongest security reasoning in this commit isn't in the installer. It's in the compose file's comment on `sandboxd`, and it's worth pulling into the light.

`sandboxd` is the only service that talks to the Docker daemon. It holds `/var/run/docker.sock`, which is root-equivalent on the host. The compose file locks down everything about the container except the socket itself:

```yaml
# deploy/release/docker-compose.yml
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
```

`read_only` makes the container's filesystem immutable. `cap_drop: [ALL]` drops every Linux capability. `no-new-privileges` prevents privilege escalation. None of that stops the socket from being root-equivalent, because the socket is the point. The hardening is for everything else: a bug in sandboxd that isn't socket-related can't escalate through a locked-down container.

The deliberate, documented choice is that sandboxd's internal API is unauthenticated. The comment chain in the compose file explains why:

```yaml
# deploy/release/docker-compose.yml
# No auth header on the API: a shared secret would have to live in
# brain's own env, the exact thing this split assumes may be
# compromised (same reasoning as memoryd's unauthenticated internal
# API).
```

The whole reason sandboxd exists as a separate service is that the brain's environment is assumed to be the thing that's compromised. A shared secret between brain and sandboxd would have to live in brain's env, which means the attacker who owns brain owns the secret too. A secret the attacker already has is security theater. So the API is unauthenticated, and the defense is network isolation instead: sandboxd sits only on `timothy-sandbox`, a second network that brain joins but searxng, markitdown, whisper, and web do not. sandboxd cannot reach any of them. Only brain can reach sandboxd.

`memoryd` uses the same reasoning. Its internal API is unauthenticated too, for the same reason: any secret brain and memoryd shared would live in the place the threat model assumes is already burned.

The workspace volume mounts to sandboxd as `:ro`, and that's also deliberate. sandboxd inspects that mount to learn the volume spec, then replicates it read-write into each sandbox container it creates. sandboxd itself never reads or writes mission files through that mount. It's metadata only.

## The magic link and the honest scope

After the stack starts, the installer polls the web UI for up to sixty seconds, then prints a link:

```
http://localhost:3300/#token=<api_token>
```

The token lives in the URL **fragment** (the part after `#`), not in the query string. This is the load-bearing security property. The HTTP spec says fragments are never sent to the server, so the token never appears in brain's access logs, never appears in any reverse proxy's access logs, and never appears in any intermediary's logs. The web app consumes it on load and strips it immediately:

```ts
// web/src/api/client.ts
// consumeTokenFragment reads a `#token=...` value from the URL fragment
// (as printed by the installer), stores it, and strips the fragment so
// it doesn't linger in the address bar or history. No-op if absent.
export function consumeTokenFragment() {
  const params = new URLSearchParams(window.location.hash.replace(/^#/, ''))
  const token = params.get('token')
  if (!token) return

  setToken(token)
  history.replaceState(null, '', window.location.pathname + window.location.search)
}
```

It's called once at app entry, in `main.tsx`, before anything renders. `history.replaceState` is used, not `pushState`: replaceState overwrites the current history entry instead of pushing a new one, so the token-bearing URL doesn't linger in the browser's back stack. After the call, the address bar reads `http://localhost:3300/` and the token is in `localStorage` under `timothy.token`, where it stays until the user clears it.

On the brain side, auth is a single static bearer token compared with `subtle.ConstantTimeCompare`. An unconfigured token fails closed with a 503, never an open server. That's the entire auth model, and it's worth being honest about its scope.

This is a **single-tenant** system. There is no users table, no per-user account, no session rotation, no token revocation, no token expiry. The one API token the installer generates is the token. Anyone who has it can drive the whole assistant; anyone who doesn't gets a 401. The magic link isn't a login flow in the OAuth sense. It's the single API token delivered through a URL fragment for UX convenience, so the user doesn't have to copy-paste a 64-character hex string into a settings screen on first boot. For a homelab assistant run by one person on their own hardware, that's an acceptable scope. It would not be acceptable for a multi-user product, and it isn't pretending to be one.

## What doesn't work yet

A few things are locked for the prebuilt path that the source path can change. The whisper image bakes the `small` model at build time; the compose file hardcodes `WHISPER_MODEL: small` with a comment that overriding requires rebuilding the image, not changing an env var. Token rotation and revocation don't exist. The auth model is one static token, full stop. These are real limits, not oversights, and they're the price of a one-line install for a single-user assistant.

Next: routes stop being hardcoded names compared by string literal, and start carrying a role and a capability instead.
