Created 2026-04-28. Designed to be picked up cold in a fresh session. Read this top-to-bottom before dispatching agent team. No prior context required beyond
~/forge/CLAUDE.md+~/forge/DEVLOG.md.
Forge currently models agents as remote webhook receivers. That works for Hermes (a daemon you run yourself) but assumes "where the agent runs" is just a URL on someone else's machine. Multica's architecture treats the runtime (the compute environment that hosts agents) as a first-class primitive distinct from the agent itself, and ships a local daemon that auto-detects available CLIs (Claude Code, Codex, Hermes, etc.) on the user's PATH.
This plan brings four of those ideas to Forge:
- Runtime as a primitive — separate from
Agent. A runtime hosts one or more agents. - Token usage tracking on
AgentRun(tokens in/out/cached, cost). - Unified agent activity timeline — interleaved view of comments,
activity events, and run events with the agent's
currentStep/ reasoning surfaced inline. forgeCLI + local daemon — auto-detects local agent CLIs, registers aRuntime, subscribes to dispatch events via SSE, spawns the appropriate CLI (initially Claude Code), streams output back via the existingchat.startDraft / appendDraftChunk / finalizeDraftMCP tools.
Out of scope (explicitly):
- Skills library as a Forge concept (Hermes already does skills well; duplicating would be confusing).
- Cloud-first deployment model (Forge stays self-hosted).
enum RuntimeKind {
LOCAL_DAEMON // `forge daemon` running on a user's machine
REMOTE_HTTP // existing Hermes-style webhook receiver
CLOUD // reserved for a future cloud-hosted runtime
}
model Runtime {
id String @id @default(cuid())
workspaceId String
name String // "Bailey's MacBook", "Hermes (docker-server)"
kind RuntimeKind
/// Webhook URL for REMOTE_HTTP. For LOCAL_DAEMON the daemon polls
/// Forge via SSE; this field stays null.
endpoint String?
/// HMAC secret for outbound webhook signing (REMOTE_HTTP only).
secret String?
/// Providers this runtime can host. For REMOTE_HTTP it's whatever
/// the operator declares; for LOCAL_DAEMON the daemon reports it
/// from PATH detection on registration + reconnect.
providersAvailable AgentProvider[]
/// Last successful heartbeat from the runtime itself. Distinct from
/// per-agent heartbeat: an idle runtime can still be alive while no
/// agent on it has done anything recently.
heartbeatAt DateTime?
/// Last connect time for LOCAL_DAEMON (when the SSE subscription
/// opened). Null for REMOTE_HTTP.
connectedAt DateTime?
/// Optional auth-token-prefix for the daemon's `forge login` token.
/// Stored only for ops display; the underlying ApiKey row is the
/// source of truth.
ownerKeyPrefix String?
/// User who registered this runtime.
ownerId String?
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull)
agents Agent[] @relation("AgentRuntime")
@@index([workspaceId, archivedAt])
@@index([ownerId])
}Add to Agent:
runtimeId String?
runtime Runtime? @relation("AgentRuntime", fields: [runtimeId], references: [id], onDelete: SetNull)Don't drop Agent.webhookUrl / webhookSecret in this round — keep
them as the canonical source for REMOTE_HTTP agents and let
runtime.endpoint be a derived/synced view. Add a brief comment in
schema: future cleanup is to make Runtime authoritative and drop the
columns. Leaving both works for backfill + zero-downtime.
model AgentRun {
// existing fields...
tokensIn Int?
tokensOut Int?
tokensCached Int?
costUsd Decimal? @db.Decimal(10, 4)
}New MCP tool runs.recordUsage({ runId, tokensIn?, tokensOut?, tokensCached?, costUsd? }) — agents call once per finished step or
once at run completion. Idempotent: latest call wins (replaces, not
adds — the run-level fields are cumulative as reported by the agent).
No schema changes. New tRPC query
agent.unifiedTimeline({ profileKey, before?: Date, limit: 50 })
that:
- Queries
CommentwhereauthoringAgentId = agent.id. - Queries
ActivityEventwhereactorId IS NULL AND subjectType = 'agent-run' AND payload->>'agentId' = agent.id(the AGENT_RUN_* family) PLUS where the event was originated by an ApiKey withlinkedAgentId = agent.id. - Queries
AgentRunEventfor all runs of this agent. - Merges client-rendered timeline rows with type discriminators
(
comment | event | run-event), sorts by timestamp descending, paginates with a cursor.
UI: replace the "Recent activity" feed on /agents/[profileKey] with
the unified timeline. Each row renders type-appropriate detail:
- Comment → markdown body, target issue link.
- AgentRunEvent → kind glyph + currentStep + payload preview.
- ActivityEvent → kind glyph + subject link.
Also wire it into the Mission Control Live tab — the RunRow already
expands into RunTimeline; that timeline can pull the wider
unifiedTimeline query when the operator wants more context.
Stack decision: TypeScript + Node, distributed as
@axiom-labs/forge-cli on npm (and via a lightweight install script
later). Starts simple — no compiled binary in v1. Lives in
packages/forge-cli/ inside the existing Forge monorepo (currently
the repo isn't a monorepo — make it one in this round, with the
existing app moving to packages/web/).
Actually, revise: changing the repo to a monorepo is a big
disruption mid-stream. Instead, put the CLI in a sibling repo or in
tools/forge-cli/ inside this repo with its own package.json. The
sibling repo route is cleaner for distribution; tools/forge-cli/ is
faster to ship.
Decision: tools/forge-cli/ with its own package.json and
tsconfig.json. The cli pubsishes via a separate npm publish flow
(out of scope for this run — for now developers use
pnpm --filter forge-cli build && node tools/forge-cli/dist/cli.js ... or a pnpm forge script wrapper).
CLI commands (v1):
forge login # device-code flow → stores at ~/.config/forge/auth.json
forge whoami # prints user, workspace, runtime registration
forge daemon start [--fg] # registers runtime, opens SSE, dispatches events
forge daemon status
forge daemon stop
forge runtimes list
forge agents list
forge issues list [--mine]
forge issue assign <key> <agent-profileKey>
Daemon flow:
- On start, reads
~/.config/forge/auth.json. If missing, promptforge loginfirst. - Auto-detects available CLIs on PATH:
which claude codex hermes gemini cursor-agent→ maps toAgentProvider[]. - Calls Forge MCP
runtimes.register(NEW) with name (defaults toos.hostname()), kind=LOCAL_DAEMON, providersAvailable. - Subscribes to
/api/realtimeSSE filtered to events targeted at this runtime — NEW server-side filter: events whose payload carriesruntimeIdmatching this caller's runtime, OR events for agents whoseruntimeIdis this runtime. - On each event:
CHAT_MESSAGE_POSTED(role=USER): look up the agent's provider → spawn the corresponding CLI with the prompt → stream stdout intochat.appendDraftChunk→chat.finalizeDraft.AGENT_ASSIGNED: spawn CLI with structured prompt for issue work; periodically postcomments.createwith progress, callruns.recordUsagewith token counts when the CLI emits them.
- Heartbeat: every 60s, call MCP
runtimes.heartbeat(NEW).
Provider adapter — Claude Code (v1 only, others stub):
- Spawn
claude --print --output-format stream-json --input-format stream-json(or whatever the current Claude Code streaming flag shape is — verify withclaude --helpat implementation time). - Pipe a JSON message into stdin: the chat body + context bundle.
- Parse stream-json output for content events; forward
deltatochat.appendDraftChunk. - On stream end, finalize.
If claude isn't available, the daemon emits a friendly error and
posts a [OFFLINE] chat reply via chat.finalizeDraft.
Migration 0018_runtime_and_token_usage:
-- Runtime
CREATE TYPE "RuntimeKind" AS ENUM ('LOCAL_DAEMON', 'REMOTE_HTTP', 'CLOUD');
CREATE TABLE "Runtime" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"kind" "RuntimeKind" NOT NULL,
"endpoint" TEXT,
"secret" TEXT,
"providersAvailable" "AgentProvider"[] NOT NULL DEFAULT '{}',
"heartbeatAt" TIMESTAMP(3),
"connectedAt" TIMESTAMP(3),
"ownerKeyPrefix" TEXT,
"ownerId" TEXT,
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Runtime_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Runtime_workspaceId_archivedAt_idx" ON "Runtime"("workspaceId", "archivedAt");
CREATE INDEX "Runtime_ownerId_idx" ON "Runtime"("ownerId");
ALTER TABLE "Runtime" ADD CONSTRAINT "Runtime_workspaceId_fkey"
FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Runtime" ADD CONSTRAINT "Runtime_ownerId_fkey"
FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Agent.runtimeId
ALTER TABLE "Agent" ADD COLUMN "runtimeId" TEXT;
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_runtimeId_fkey"
FOREIGN KEY ("runtimeId") REFERENCES "Runtime"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "Agent_runtimeId_idx" ON "Agent"("runtimeId");
-- Backfill: every existing agent with a webhookUrl gets a REMOTE_HTTP
-- runtime that wraps it. Reuse the agent name; reuse the webhookSecret.
-- Run as a single statement so a fresh deploy is cheap.
INSERT INTO "Runtime" (id, "workspaceId", name, kind, endpoint, secret,
"providersAvailable", "heartbeatAt", "ownerId",
"createdAt", "updatedAt")
SELECT
'rt_' || a.id, -- deterministic id derived from agent
a."workspaceId",
a.name || ' (legacy webhook)',
'REMOTE_HTTP',
a."webhookUrl",
a."webhookSecret",
ARRAY[a.provider]::"AgentProvider"[],
a."lastHeartbeatAt",
NULL, -- no owner attribution for legacy rows
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "Agent" a
WHERE a."webhookUrl" IS NOT NULL;
UPDATE "Agent" a
SET "runtimeId" = 'rt_' || a.id
WHERE a."webhookUrl" IS NOT NULL;
-- AgentRun token columns
ALTER TABLE "AgentRun" ADD COLUMN "tokensIn" INTEGER;
ALTER TABLE "AgentRun" ADD COLUMN "tokensOut" INTEGER;
ALTER TABLE "AgentRun" ADD COLUMN "tokensCached" INTEGER;
ALTER TABLE "AgentRun" ADD COLUMN "costUsd" DECIMAL(10, 4);Update prisma/schema.prisma to mirror these.
Owner: one subagent.
Files:
prisma/schema.prisma— addRuntimeKindenum,Runtimemodel,Agent.runtimeId,AgentRun.tokensIn/Out/Cached/costUsd.- NEW:
prisma/migrations/0018_runtime_and_token_usage/migration.sql— exactly the SQL above. - NEW:
src/server/routers/runtime.ts— tRPC router with:runtime.list()— workspace runtimes.runtime.byId({ id }).runtime.register({ name, kind, endpoint?, providersAvailable })— creates a runtime; for LOCAL_DAEMON sets ownerId from the calling user. Returns{ runtime, daemonToken? }(daemonToken is the existing API key the user used to authenticate; we don't issue new ones).runtime.heartbeat({ id })— bumpsheartbeatAt. Used by daemon every 60s; mirrorsagent.heartbeat.runtime.archive({ id }).runtime.update({ id, name?, providersAvailable? }).
src/server/routers/_app.ts— register the new router.src/server/services/mcp.ts— add three MCP tools:runtimes.register— wraps the tRPC mutation, reads workspace/owner fromctx.apiKey.runtimes.heartbeat— bumpsheartbeatAt.runs.recordUsage({ runId, tokensIn?, tokensOut?, tokensCached?, costUsd? })— updates the AgentRun. Validates the calling key'slinkedAgentIdowns the run.
src/server/routers/agent.ts— extendagent.listandagent.byIdto includeruntime(id, name, kind, heartbeatAt) so the UI can render runtime info inline.src/server/routers/agent.ts— addagent.unifiedTimeline(described in architecture above).
Acceptance:
pnpm typecheckclean.pnpm prisma migrate deploysucceeds against dev DB; existing agents get alegacy webhookruntime; theirruntimeIdis set.runtime.listreturns the backfilled rows.
Owner: one subagent.
Files:
- NEW:
src/app/(app)/w/[slug]/settings/runtimes/page.tsx— runtimes index. Card per runtime with kind badge, providers, last heartbeat, owner avatar, host count of agents. Actions: rename, archive, view details. - NEW:
src/app/(app)/w/[slug]/settings/runtimes/[id]/page.tsx— detail page with the agents on this runtime + a "How to connect a new daemon" pane that surfaces a copy-pasteableforge login --workspace <slug>block and the SSE channel id. src/components/settings/settings-navbar.tsx— add{ href: w("/runtimes"), label: "Runtimes", Icon: Server }to the Integrations group.src/components/mission-control/agents-tab.tsx— render runtime badge alongside the existing runtime-mode pill.src/components/mission-control/run-row.tsx— when ETA query resolves with usage data (after Stream E lands), show a small42k tokchip next to the elapsed time.src/app/(app)/w/[slug]/agents/[profileKey]/page.tsx— add a "Runtime" card to the existing dashboard showing runtime.{name,kind,heartbeatAt,providersAvailable}.
Acceptance:
- Runtimes page lists backfilled rows after
pnpm prisma migrate deploy. - Settings navbar shows Integrations → Runtimes link active when on the page.
pnpm typecheckclean.
Owner: one subagent.
Files:
- The
agent.unifiedTimelinequery is added inside Stream A (because it lives next to the existing agent router). Stream C consumes it. - NEW:
src/components/agents/agent-timeline.tsx— timeline component. Renders a chronological list with type-discriminated rows. Includes a load-more cursor at the bottom. ~200 lines. src/app/(app)/w/[slug]/agents/[profileKey]/page.tsx— replace the existing "Recent activity" feed with<AgentTimeline />.src/components/mission-control/run-timeline.tsx— extend to optionally include surrounding comment + activity event rows when the operator expands a run row in MC's Live tab. Use the sameagent.unifiedTimelinequery but scoped to the run's time window.
Acceptance:
- Agent detail page shows interleaved comments + run events + activity events sorted by timestamp.
- Loading more pages works via cursor.
pnpm typecheckclean.
Owner: one subagent.
Files:
- NEW:
tools/forge-cli/package.json—name: "@axiom-labs/forge-cli", "type": "module", "bin": { "forge": "./dist/cli.js" }. Deps:commander,@trpc/client,eventsource,chalk, the@prisma/clienttypes if needed (or hand-rolled types). Avoidpkg/nexefor v1. - NEW:
tools/forge-cli/tsconfig.json. - NEW:
tools/forge-cli/src/index.ts— entry, top-levelcommandersetup. - NEW:
tools/forge-cli/src/auth.ts—~/.config/forge/auth.jsonread/write with mode 600. Stores{ url, token, workspaceSlug }. - NEW:
tools/forge-cli/src/login.ts—forge logincommand. v1 simplification: takes URL + token via prompt or--tokenflag rather than full device-code OAuth. Validate by callingauth.session.useQuery(or any cheap workspaceProcedure) to confirm the token works. - NEW:
tools/forge-cli/src/daemon.ts—forge daemon {start,stop, status}.- On start: spawn a child process detached (or run foreground if
--fg). The child:- Detects
claude,codex,hermes,gemini,cursor-agenton PATH (whichshell calls). - Calls MCP
runtimes.register(creates or updates a runtime row keyed by hostname + ownerId). - Opens
/api/realtimeSSE withAuthorization: Bearer <token>. Filter client-side for events whose payload'sruntimeIdmatches OR whose subjectId is an agent on this runtime. - Sends MCP
runtimes.heartbeatevery 60s.
- Detects
- PID file at
~/.config/forge/daemon.pidsostatus/stopwork.
- On start: spawn a child process detached (or run foreground if
- NEW:
tools/forge-cli/src/dispatch/claude-code.ts— adapter forclaudeCLI. Takes a chat thread context, spawns claude CLI with appropriate flags, parses stream output, calls Forge MCPchat.startDraft/appendDraftChunk/finalizeDraft. - NEW:
tools/forge-cli/src/dispatch/index.ts— provider switch. - NEW:
tools/forge-cli/src/commands/{whoami,issues,agents,runtimes}.ts— read-only commands. - NEW:
tools/forge-cli/README.md— install + first-run. package.json(root) — add"forge": "node tools/forge-cli/dist/cli.js"script for in-repo dev use, and"build:cli": "tsc -p tools/forge-cli".
Acceptance:
pnpm build:cliproducestools/forge-cli/dist/cli.js.pnpm forge whoamiprints the authenticated user (afterforge login).pnpm forge daemon start --fgregisters a runtime row in the dev DB, sends heartbeats, and prints SSE event arrivals to stdout.- Sending a chat message to an agent on the local runtime via the web UI causes Claude Code to spawn (if installed) and stream a reply back through the chat draft tools — visible in the chat thread.
- If
claudeisn't on PATH, the daemon posts a friendly fallback message instead of crashing.
Folded into Stream B's run row + agent detail card, called out
separately so subagents understand the dependency. The actual surfacing
work is small (~3 small render additions) once tokensIn / tokensOut
are in the agent.list / agentRun.activeAll responses.
Round 1 (parallel — schema MUST land first):
- A (backend foundations) — runs first. Subagent posts a "schema shipped" signal when migration applies cleanly.
Round 1 (parallel with A, since they only read existing tables):
- C (unified timeline) — backend query + UI component. The query
is added in A's file (
agent.ts); C provides only the consuming UI. C can scaffold the component against a stubbed query shape and swap to the real hook once A lands. - D (CLI + daemon) — can scaffold + write provider adapter + SSE subscription against the spec. Can't fully test until A's MCP tools land, but compile-time work proceeds.
Round 2 (after A):
- B (Forge UI: runtimes + token surfaces) — depends on
runtime.listquery and the agent select extension. - D (CLI integration tests) — once A lands, daemon can register
- heartbeat + receive events.
Round 3 (cleanup):
- Verify, lint, commit, deploy. Single subagent.
Coordination notes:
- Stream A owns
agent.tsfor the unifiedTimeline addition. Stream B also touchesagent.ts(re: extending list/byId selects). Have Stream A finish first; Stream B re-reads the file before editing. Or: have A own allagent.tsedits and B just consume. - Stream A and Stream D both touch MCP tools. Have A own the new MCP
tools (
runtimes.register,runtimes.heartbeat,runs.recordUsage); D consumes them.
After all streams converge:
pnpm typecheck && pnpm lintclean (only pre-existing warnings).pnpm prisma migrate deployapplies 0018 cleanly; existing agents have alegacy webhookruntime backfilled./settings/runtimeslists at least the legacy backfill rows./agents/<profileKey>shows the unified timeline (interleaved comments + run events + activity events) with a load-more cursor that works.pnpm forge whoamiandpnpm forge daemon start --fgwork end-to-end against the dev server.- With Claude Code installed locally, sending a chat message to an agent assigned to the local runtime triggers a Claude Code spawn, streams a reply back via the chat draft tools, and the reply renders progressively in the chat UI.
runs.recordUsagewrites token columns; the agent detail page + MC RunRow render token chips when present.- Mission Control Live tab still works for Hermes-routed runs (REMOTE_HTTP path unchanged).
- OAuth device-code flow for
forge login— v1 takes URL + token via prompt/flag. - Compiled binary for the CLI (
pkg/nexe/bun build --compile). - Provider adapters beyond Claude Code (Codex, Gemini, Cursor Agent follow the same pattern but each has its own stream-output flag shape).
- Cost-source-of-truth: rates per model. v1 takes
costUsdfrom the agent verbatim; we'll move to a server-side table later. - Per-runtime token budget caps and alerts.
- Skills library Forge-side surface (deliberately deferred).
- The Forge dev DB is reachable at
forge-postgres(already true). - Hermes daemon is running and the existing forge-presence cron is installed (already true). The old REMOTE_HTTP path keeps working through the migration.
- For Stream D end-to-end testing:
claudeCLI installed on the host. If not installed, the daemon can still register + heartbeat; the spawn path will hit the "fallback offline" branch.
- Do NOT drop
Agent.webhookUrl/webhookSecretcolumns. Keep them as legacy/source-of-truth for REMOTE_HTTP runtimes; the cleanup is a future migration once everything is on the Runtime model. - Do NOT touch the existing chat streaming MCP tools (
chat.*) — those are stable. The CLI / daemon CONSUME them, not modify. - Do NOT touch Hermes-side files (
~/.hermes/...). The Hermes integration keeps working through the legacy REMOTE_HTTP runtime row; no Hermes changes needed for this round. - Do NOT bundle the CLI as a binary — that's a packaging follow-up.
- Read
~/forge/CLAUDE.mdand~/forge/DEVLOG.mdfor context. - Read this PLAN.md end-to-end.
- Dispatch Stream A. Wait for "schema shipped" signal (migration applied cleanly + typecheck clean).
- Dispatch Streams B, C, D in parallel after A signals done.
- Stream E folds into B's commits.
- Run combined typecheck + lint + smoke tests.
- Commit each stream as a single logical commit. Push.
- Rebuild + redeploy Forge container.
- Update
DEVLOG.mdwith a session entry. Update CLAUDE.md if any primitives changed (Runtime added). - Update VitePress docs (
docs/agents/runtimes.mdnew,docs/reference/mcp.mdfor new tools,docs/reference/trpc.mdfor the runtime router).
Total estimated time: 3–5 hours with an agent team running in parallel.
Plan author: prior session (Claude Opus 4.7, 1M context). Hand-off: see top of file.