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:
// 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:
/* 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:
/* 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 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*:
# .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:
# .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:
# .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):
# .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:
# .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:
# 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:
# 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:
# 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:
# 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:
# 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:
// 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.