The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
10 KiB
agent-proxy
Standalone Hono / Node.js service that replaces the Python ASGI agent-proxy
(products/tasks/backend/proxy.py). Serves the task-run live event plane:
SSE stream reads for browsers and NDJSON event ingest from sandboxes, both
backed by the same Redis streams that Django writes.
No Postgres, no Temporal client, no Celery — the service is a pure streaming plane. Side effects (Temporal heartbeats, awaiting-input push notifications) are delegated to a single internal Django callback endpoint.
Routes
| Method | Path | Description |
|---|---|---|
GET |
/v1/runs/:run/stream |
SSE read — streams task-run events to browsers |
POST |
/v1/runs/:run/ingest |
NDJSON ingest — accepts events from sandbox agents |
GET |
/_health |
Liveness probe |
GET |
/_readyz |
Readiness probe (returns 503 while shutting down) |
GET |
/health |
Liveness probe (alias) |
GET |
/_metrics |
Prometheus metrics |
OPTIONS |
* |
CORS preflight (204) |
Tokens are accepted via Authorization: Bearer <token> on both legs. There is
no ?token= query fallback: query strings leak into upstream infrastructure
access logs, which would expose the run-scoped JWT. The browser uses
fetch-event-source (which sets headers), not native EventSource.
Resume is via the Last-Event-ID header; ?start=latest tails from the
newest available entry.
Environment variables
| Variable | Required | Default | Description |
|---|---|---|---|
TASKS_REDIS_URL |
yes (prod) | localhost:6379 |
Redis connection URL (unencrypted local Redis by default; use a TLS URL in production) |
SANDBOX_JWT_PUBLIC_KEY |
yes | — | RS256 public key PEM (\n literals in env vars are normalized to real newlines before use) |
AGENT_PROXY_DJANGO_CALLBACK_URL |
yes (prod) | — | Base URL of the Django service for side-effect callbacks (Temporal heartbeat, awaiting-input push), e.g. http://web:8000 |
AGENT_PROXY_CALLBACK_SECRET |
no | '' |
Shared secret sent as X-Agent-Proxy-Secret on the Django callback; Django enforces it when the same value is set on both sides, so a sandbox cannot call the callback directly |
TASKS_AGENT_PROXY_CORS_ORIGINS |
no | '' |
Comma-separated allowed CORS origins; * allows all |
PORT |
no | 8003 |
HTTP listen port |
HOST |
no | 0.0.0.0 |
HTTP listen address |
SHUTDOWN_GRACE_MS |
no | 300000 |
Maximum drain budget on SIGTERM before force-exit (ms) |
SHUTDOWN_PRESTOP_DELAY_MS |
no | 0 |
Sleep before closing on SIGTERM (useful for Kubernetes preStop hooks) |
NODE_ENV |
no | — | production enables strict startup checks |
AGENT_PROXY_LOG_LEVEL |
no | debug dev / info prod |
One of debug, info, warn, error. debug logs every connect / read / write |
Running locally (end to end)
Both stream legs (browser read + sandbox ingest) route to this service when
Django has the proxy URLs set and runs with DEBUG=True. Local dev disables the
analytics SDK, so the URL settings are the opt-in (no feature flag needed
locally).
All config is read from the repo-root .env, which flox loads into every process
(DOTENV_FILE = ".env" in the flox manifest) — the same place Django, the
Temporal worker, and the other services read from. There is no per-service .env
to manage. (The dev script will also load an optional services/agent-proxy/.env
override if one exists, mirroring services/mcp, but it is not needed.) flox
caches the dotenv, so re-activate your shell or restart insightscli after editing
.env.
1. Point Django at the proxy
In the repo-root .env (read by Django web and the Temporal worker):
DEBUG=1
TASKS_AGENT_PROXY_PUBLIC_URL=http://localhost:8003 # browser read leg
TASKS_AGENT_PROXY_INGEST_URL=http://localhost:8003 # sandbox ingest leg (auto-rewritten for the Docker sandbox)
2. Give this service its verify key + CORS
Same repo-root .env. This service is verify-only, so it needs the public half
of Django's SANDBOX_JWT_PRIVATE_KEY. Derive it (double-quote the value, like
the private key, so the PEM newlines survive flox's dotenv parser):
# from repo root, inside flox — prints the line to add to .env
python3 - <<'PY'
import re, pathlib
from cryptography.hazmat.primitives import serialization
priv = re.search(r'^SANDBOX_JWT_PRIVATE_KEY=(.*)$', pathlib.Path(".env").read_text(), re.M).group(1).strip().strip('"').strip("'")
key = serialization.load_pem_private_key(priv.replace("\\n", "\n").encode(), password=None)
pub = key.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode()
print('SANDBOX_JWT_PUBLIC_KEY="' + pub.replace(chr(10), "\\n") + '"')
PY
Add that line plus these to the root .env:
SANDBOX_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n"
TASKS_AGENT_PROXY_CORS_ORIGINS=http://localhost:8010
AGENT_PROXY_DJANGO_CALLBACK_URL=http://localhost:8000 # heartbeat + awaiting-input callbacks; omit to skip them
TASKS_REDIS_URL is optional locally — it defaults to localhost:6379, the
same instance Django uses (the redis7 host maps to localhost).
3. Start
insightscli start runs this service automatically when the ai_features intent is
enabled, listed as agent-proxy on :8003 (watch mode). Enable the intent with
insightscli dev:setup if it is not already on.
insightscli start # Redis, Django (:8000), frontend (:8010), Temporal worker, agent-proxy (:8003)
To run only this service standalone (for example when ai_features is off):
insightscli start:agent-proxy # this service on :8003 (watch mode); or: pnpm --filter @hanzo/agent-proxy dev
The proxy must be running before you start a task run. With the URL settings above there is no automatic failover: if the proxy is down the client fails to connect, it does not fall back to Django.
4. Verify both legs
Create a run at http://localhost:8010/project/1/tasks, then:
- Read leg: in browser DevTools > Network the
/v1/runs/:run/streamEventStream request host islocalhost:8003(not:8000). The/stream_token/response carriesstream_base_url: http://localhost:8003. - Write leg: the proxy console logs ingest activity as the sandbox POSTs, and events render live in the task UI.
- Health:
curl localhost:8003/_healthandcurl localhost:8003/_metrics.
Rolling back to Django
Unset TASKS_AGENT_PROXY_PUBLIC_URL and TASKS_AGENT_PROXY_INGEST_URL in the
root .env and restart. No data migration is needed: the Redis stream format is
identical, so the Django in-process path picks up seamlessly.
Building
# From repo root
docker build -f services/agent-proxy/Dockerfile -t insights-agent-proxy .
# Or build the JS bundle only (no Docker)
pnpm --filter @hanzo/agent-proxy build
# Output: services/agent-proxy/dist/agent-proxy-server.mjs
Testing
pnpm --filter @hanzo/agent-proxy test
Type checking
pnpm --filter @hanzo/agent-proxy typecheck
Cutover note
Browsers discover the proxy URL from the stream_base_url field returned by
the Django stream_token endpoint. When TASKS_AGENT_PROXY_PUBLIC_URL is set
on Django (read leg) it points to this service; the write leg routes here when
TASKS_AGENT_PROXY_INGEST_URL is set. When unset, browsers and sandboxes use
the Django in-process path instead — no client code changes are needed for the
cutover.
The Redis stream key format (task-run-stream:{run_id}) and all companion
keys are byte-identical between this Node service and the Python implementation.
During the cutover window both services read and write the same streams safely.
To roll back: unset TASKS_AGENT_PROXY_PUBLIC_URL and TASKS_AGENT_PROXY_INGEST_URL
on Django. No data migration is needed because the stream format is identical.
S3 hydration on resume gap is explicitly out of scope for this version — when a
client's Last-Event-ID has been trimmed from Redis, the service emits a metric
and continues reading from the oldest available entry, matching the current
Python behavior exactly.