You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Tracking issue for converging the async / scheduled chat-generation path onto the same durable Vercel Workflow + native-sandbox architecture that interactive /api/chat already runs in prod, and retiring the OpenClaw run-sandbox-command Trigger.dev task. Scheduling stays in Trigger.dev; generation/agent work moves into api.
Why now: the scheduled weekly-report path is currently failing for every account. customer-prompt-task → POST /api/chat/generate (legacy ToolLoopAgent) → the agent calls the prompt_sandbox MCP tool → tasks.trigger("run-sandbox-command") → OpenClaw bootstrap in the sandbox, which exits non-zero. The agent degrades gracefully and emails a partial report, so it looks successful but ships incomplete data. The OpenClaw layer is the failure surface; rather than patch soon-to-be-deprecated tasks code, we remove it by unifying onto the architecture we've already shipped.
Goal
Async chat generation runs on the same runAgentWorkflow durable workflow as interactive /api/chat — an AI SDK agent (streamText on @ai-sdk/gateway) with native in-sandbox tools (bash/read/write/edit/grep/glob) executing inside a Vercel Workflow. Concretely, when done:
POST /api/chat/generate provisions/attaches a session+sandbox and start()s runAgentWorkflow instead of running a synchronous ToolLoopAgent (api/lib/chat/handleChatGenerate.ts).
The prompt_sandbox MCP tool → processCreateSandbox → triggerPromptSandbox → run-sandbox-command bridge is gone — the agent has native sandbox tools in-workflow.
OpenClaw (install/onboard/gateway/skills/model-config) and the run-sandbox-command task are deleted from tasks.
Trigger.dev keeps only scheduling/light orchestration (customer-prompt-task + createSchedule/updateSchedule); no generation logic remains in tasks.
Implementation PRs (2026-06-24): merge order docs#249 ✅ → api#704 → api#705 → tasks#152. docs#249 (the contract) is merged to main; the endpoint was renamed during review from /api/chat/generate to POST /api/chat/runs (REST: it creates a run), returns 202 { runId, chatId, sessionId } (so the result is retrievable, not fire-and-forget-only), and documents GET /api/chat/runs/{runId} (status). The status endpoint is implemented in api#704 and preview-verified (running→completed); its response was reconciled to { runId, status } via docs#250 (merged main, 83754b8) — getRun(runId) exposes only status and the caller already holds chatId/sessionId from the 202, so they're not echoed (full chatId/sessionId + per-run ownership would need a chats.last_run_id column, deferred). api#704/#705/tasks#152 were re-pointed to /api/chat/runs to match; api#704 is preview-verified end-to-end. Phase 2 (delete OpenClaw from tasks) remains not opened — gated on prod stability.
Scope correction (2026-06-24): api#700 was narrowed to expiry enforcement only. The mintEphemeralAccountKey helper + the insertApiKeyexpires_at writer change were removed from it (commit 9302dde) — neither had a caller in that PR, so they'd ship as untestable dead code (violating the "every PR is a testable slice" rule). The minting code now lands with the re-point item below, which is its only consumer; it is preserved verbatim there so nothing is lost.
✅ Functional migration COMPLETE (2026-06-24). All seven implementation PRs are merged and on prod: the re-point (api#704), the OpenClaw bridge retirement (api#705), and the tasks fire-and-forget cutover (tasks#152). The scheduled path now runs entirely on runAgentWorkflow with native sandbox tools + a per-run ephemeral key (mint→inject→authenticate→revoke, verified end-to-end). Nothing triggers run-sandbox-command anymore. Only Phase 2 remains — deleting the now-dormant OpenClaw code (install/onboard/gateway/skills/model-config + the run-sandbox-command task) from the tasks repo — intentionally gated on the unified path proving stable in prod first. Two non-blocking fast-follows surfaced in testing: (1) the sandbox agent has no email-sending capability yet; (2) RECOUP_ACCESS_TOKEN carries an API key but is named/documented as a JWT, so the recoup-api skill should use the x-api-key header.
Done
database#36 — add account_api_keys.expires_at (ephemeral-key TTL).
✅ Merged 2026-06-24 to main (merge commit fada52e; database is single-branch — no test/main split). First PR in the docs→database→api→tasks order.
Adds a nullable expires_at timestamptz to public.account_api_keys via ADD COLUMN IF NOT EXISTS — additive, backward-compatible, no data migration. NULL = never expires, so all existing long-lived keys are unaffected; a column COMMENT documents the semantics and points back here. Unblocks api#700, which enforces the TTL in getApiKeyAccountId (rejecting expired keys) as defense-in-depth behind the per-run delete-on-completion.
Verified: merged migration supabase/migrations/20260623000000_add_expires_at_to_account_api_keys.sql present on main at fada52e (19 additions, single ALTER + COMMENT, no down-migration needed). Applying the column to the live/target DB is the operational step that gates the queued re-point PR (above).
api#700 — enforce account_api_keys.expires_at in x-api-key auth.
✅ Merged 2026-06-24 to test (merge commit 03e0ef5), then promoted to main/prod via api#702 (test→main, merge commit 6b62b74, 2026-06-24); enforcement confirmed live on main at getApiKeyAccountId.ts:53. Scope: enforcement only — isApiKeyExpired (pure TTL check; NULL/unparseable = never expires) wired into getApiKeyAccountId, which now returns 401 for a key past its expires_at. Minting was deliberately removed (see scope-correction note above) and moves to the re-point item below.
Verified end-to-end on the preview deployment (api-git-feat-ephemeral-account-api-keys-recoup.vercel.app, commit 9302dde) against GET /api/accounts/id, by creating one real recoup_sk_ key in account_api_keys (prod DB godremdqwajrwazhbrue, which the preview reads; expires_at column confirmed present) and varying onlyexpires_at: NULL → 200, future (+1h) → 200, past (−1h) → 401 (deterministic), row deleted → 401. The future→past flip on the same key isolates isApiKeyExpired as the cause; long-lived/NULL-expiry keys are unaffected. Test results comment. (Note: the preview's PRIVY_PROJECT_SECRET differs from local, so the stored hash must be computed with the preview secret.)
api#701 — extract shared buildRunAgentInput() (behavior-preserving).
✅ Merged 2026-06-24 to test (merge commit 78bd71b), then promoted to main/prod via api#703 (test→main, merge commit 0c94809, 2026-06-24); lib/chat/buildRunAgentInput.ts confirmed present on main. Extracts the RunAgentWorkflowInput construction out of handleChatWorkflowStream into one builder (repo ids + recoup org id both derived from clone_url inside it — single source of truth) so the interactive and headless callers stay in lockstep. The interactive path (handleChatWorkflowStream.ts:124) is the live consumer; the headless re-point PR will be the second.
Verified end-to-end on the preview (api-git-feat-build-run-agent-input-recoup.vercel.app, head 5ee07e07) by driving the real interactive flow with a Privy Bearer token: POST /api/sessions → 200; POST /api/sandbox → 200 (ready 5.6s); POST /api/chat → 200text/event-stream, x-workflow-run-id: wrun_01KVWZNM82NA7XKNEWWHG8VPHJ, streamed assistant text PR701 OK (model followed the instruction exactly; anthropic/claude-haiku-4.5), 1 assistant message persisted. Proves the builder yields a RunAgentWorkflowInput the workflow accepts and the agent streams a real response — behavior-preserving. Test session/chat/message deleted afterward. Test results comment.
docs#249 — POST /api/chat/runs contract (the docs lead the api).
✅ Merged 2026-06-24 to main (merge commit 58f442a; docs is single-branch). First in the docs→api→tasks order.
Renamed POST /api/chat/generate → POST /api/chat/runs (REST: it creates a run, not a generate verb) and changed the contract from a synchronous 200 completion to 202 { runId, chatId, sessionId } + a Location header — so a caller can retrieve the result (GET /api/chat/{chatId}/stream, or the persisted messages) instead of fire-and-forget-only. Added GET /api/chat/runs/{runId} (status snapshot — ChatRunStatusResponse; api implementation deferred to a follow-up). Trimmed the request body to prompt | messages, artistId, model (dropped legacy excludeTools / roomId / topic to match /api/chat). Bidirectional cross-links added across the four chat endpoints. /api/chat/generate removed (no alias).
Verified: api-reference/chat/runs.mdx present on main at 58f442a; OpenAPI (research.json) carries /api/chat/runs + /api/chat/runs/{runId}. api#704/Telegram bot - missing errors #705/tasks#152 were re-pointed to /api/chat/runs to fulfill this contract.
api#704 — re-point POST /api/chat/runs onto runAgentWorkflow (the keystone).
✅ Merged 2026-06-24 to test (merge commit 2209b7de), then promoted to main/prod via api#706 (test→main, merge commit 6abaad68, 2026-06-24); app/api/chat/runs/route.ts, the {runId} status route, and the lib/chat/runs/ rename all confirmed on main. (Telegram bot - new conversations - hide test emails #706 needed a one-file conflict resolution in buildRunAgentInput.ts — took the test side with ephemeralKeyId, since main only lagged behind.) This is the consumer that re-adds the minting deferred from api#700.
Provisions a headless session + active sandbox (shared createSessionWithInitialChat + connectSandbox + markSessionSandboxActive), mints a per-run ephemeral recoup_sk_ key → injects as recoupAccessToken (never the service key) → threads agentContext.ephemeralKeyId → start(runAgentWorkflow) → returns 202 { runId, chatId, sessionId } + Location. The workflow's finally revokes the key on run end (deleteEphemeralKeyStep); the ~15m TTL is the backstop. Also implements GET /api/chat/runs/{runId} (status). Scope evolved in review: /generate→/runs rename, dropped legacy excludeTools/roomId/topic, SRP/DRY extractions shared with the interactive /api/sessions+/api/sandbox handlers, and lib/chat/generate/→lib/chat/runs/ rename.
Verified end-to-end on the preview across the review iterations (latest 10a727ce): POST /api/chat/runs → 202; GET …/{runId} → running→completed; 404 unknown run; 401 no-auth; the agent ran real native bash tool calls (e.g. echo rename-verified → stdout rename-verified) observable via the persisted chat by chatId; ephemeral key auto-revoked (0 left) each run; all test artifacts cleaned up. Observability test comment.
api#705 — retire the OpenClaw prompt_sandbox → run-sandbox-command bridge.
✅ Merged 2026-06-24 to test (merge commit 4c09cf06), then promoted to main/prod via api#707 (test→main, merge commit 83a269ea); registerPromptSandboxTool.ts confirmed gone from main.
Removed the prompt_sandbox MCP tool + its registration, triggerPromptSandbox (the run-sandbox-command trigger), processCreateSandbox's prompt mode, and the POST /api/sandboxes prompt/runId path (now account_id-only). Folded in the prompt cleanup once the audit showed the legacy getGeneralAgent stack is live (Slack chat + inbound email), not dead: stripped the prompt_sandbox "Sandbox-First" section from SYSTEM_PROMPT and the (use prompt_sandbox for those) pointer from create_knowledge_base, with guard tests on both. Behavior note: Slack + email agents lose the OpenClaw sandbox tool — fine, since OpenClaw is the failing component being removed; those two still run on the legacy getGeneralAgent stack (not runAgentWorkflow), so that stack can't be fully deleted yet (out of scope here).
Verified on the preview (65dd8d05): live MCP tools/list (39 tools) shows prompt_sandbox removed and no sandbox tools remain; create_knowledge_base's live description no longer mentions it; POST /api/sandboxes with a legacy {prompt} returns {sandboxes:[…]} with no runId (prompt ignored). Test results comment.
tasks#152 — generateChat fire-and-forget (the final cutover PR).
✅ Merged 2026-06-24 to main (merge commit 39b877da). generateChat now POSTs ${NEW_API_BASE_URL}/api/chat/runs, consumes the 202 { runId } (logs/returns it for observability only), and does not await generation — persistence + side effects run server-side in the durable workflow. customer-prompt-task returns right after the kickoff. With this merged, the scheduled path runs entirely on runAgentWorkflow and nothing triggers run-sandbox-command. Verified end-to-end through the live path (local tasks Trigger.dev dev → customer-prompt-task → /api/chat/runs prod → durable run): task completed in 6.7s without awaiting, kickoff returned 202 { runId: wrun_01KVXY569… }. A diagnostic run confirmed the ephemeral-key architecture works: the sandbox agent's shell got $RECOUP_ACCESS_TOKEN = a recoup_sk_ key (len 53), authenticated to the Recoup API (/api/accounts/id → 200, correct accountId) via the x-api-key header, and the key was revoked on run end (0 ephemeral:* keys linger). Env-matching holds: the prod-minted token is valid against prod (200) and rejected by test-recoup-api (401). Test results comment. Known fast-follow (non-blocking): the new sandbox agent has no email-sending capability yet (the "Daily Hello World Email" test run completed but reported no email tool), and RECOUP_ACCESS_TOKEN carries an API key while its name/JSDoc imply a Privy JWT — align so the recoup-api skill uses x-api-key.
Thrown at tasks/src/sandboxes/runSetupSandboxSkill.ts:23 ("Failed to set up sandbox via OpenClaw"), reached via ensureSetupSandbox.ts:34 ← runSandboxCommandTask.ts:68.
Underlying OpenClaw stderr: GatewayClientRequestError: ... All models failed (2): vercel-ai-gateway/anthropic/claude-opus-4.6: Unknown model .... The model id is valid in the live gateway catalog, so this is a runtime model/catalog-resolution failure in the OpenClaw gateway daemon (candidate causes: gateway daemon not receiving AI_GATEWAY_API_KEY; onboard-default model vs. allowlist mismatch). Root cause was not conclusively pinned — which is itself the argument for removing OpenClaw rather than patching it.
Open — immediate (this week, out-of-band)
Manually generate + deliver the current week's report for the affected scheduled-report customer. (no PR)
✅ Shipped 2026-06-22 — pulled all five /api/research/* endpoints live (HTTP 200, real data), composed the report in the task's structure, and emailed it from agent@recoupable.com (Resend accepted, message id 35ca1a58-38e6-4cbe-b829-5467f9e8ad63). Details in the closure comment. Out-of-band; not part of the migration's acceptance.
Open — design spikes (derisk before implementation)
Short-lived, account-scoped token for in-sandbox recoup-api calls.
Why: the new agent calls recoup-api via bash+curl using $RECOUP_ACCESS_TOKEN; the interactive path forwards a short-lived Privy JWT and deliberately does not forward the long-lived service key into model-driven bash (api/lib/chat/handleChatWorkflowStream.ts:148-152). The scheduled caller only holds the service x-api-key.
✅ Resolved (2026-06-23): an ephemeral, account-scoped recoup_sk_ key (not a new JWT). Privy JWTs can't be issued server-side (@privy-io/node is verify-only), so we reuse the existing api-key primitive: add account_api_keys.expires_at, mint a ~15m-TTL single-account key per run, inject as $RECOUP_API_KEY, delete on run end; auth rejects expired keys. Reproduces the interactive trust profile (short-lived single-account bearer in the sandbox). PRs: database#36 (column), api#700 (expiry enforcement only — minting deferred to the re-point PR; see the scope-correction note above). Decision comment.
Headless session + active-sandbox provisioning.
Why:runAgentWorkflow requires a live session with an active sandbox before start() (api/lib/chat/handleChatWorkflowStream.ts:67 → 400 "Sandbox not initialized"); the legacy path lazily span one up via prompt_sandbox, so the scheduled path pre-provisions nothing.
✅ Resolved (2026-06-23): reuse existing lib functions headlessly — ensurePersonalRepo → insertSession(buildSessionInsertRow) → insertChat, then connectSandbox + updateSession(sandbox.getState() / buildActiveLifecycleUpdate) until isSandboxActive. Scheduled caller has accountId (+ optional artistId/roomId) and mints the session+chat. Implemented in the re-point PR.
Tool-parity decision: research + email as first-class AI SDK tools vs. skill-driven curl.
Why:buildAgentTools (api/lib/agent/buildAgentTools.ts) ships native sandbox/file tools only — no send_email, no research, no Composio. The legacy generate path got those via MCP + Composio (api/lib/chat/setupToolsForRequest.ts:20-22). Moving the report onto the new agent loses email unless addressed.
✅ Resolved (2026-06-23):(a) curl-via-recoup-api-skill — keeps the broad recoup-api surface (research + email) without enumerating native tools; depends on the ephemeral key above for $RECOUP_API_KEY.
Open — implementation (in merge order)
Documentation-driven; sequence docs → database → api → tasks, honoring hard deps. (Prereqs buildRunAgentInput extraction [api#701] + expires_at enforcement [api#700] are done + on prod — see Done.)
api: re-point POST /api/chat/runs to provision + start(runAgentWorkflow) and return 202 { runId, chatId, sessionId }. → PR api#704 ✅ merged to test (prod promotion pending) — see Done. Re-adds mintEphemeralAccountKey + insertApiKey writer; new validateGenerateRequest + provisionGenerateSession; mints key → injects as recoupAccessToken → threads agentContext.ephemeralKeyId; runAgentWorkflow's finally deletes the key on run end (deleteEphemeralKeyStep). Preview test: POST /api/chat/generate → 202{ runId: wrun_01KVX2RBB3JDZFA0131B3RT1QS } → assistant message PR704 OK persisted → ephemeral key revoked. Contract = docs#249.
Why safe to change the contract: the only caller, tasks/src/recoup/generateChat.ts, uses the response only for logging (textPreview/usage) — persistence + email happen server-side. (tasks side: tasks#152.)
Includes the ephemeral-key minting removed from api#700 (the missing piece). Re-add lib/keys/mintEphemeralAccountKey.ts + the insertApiKeyexpires_at writer param, then call them here as part of the headless provision step. Expiry enforcement (isApiKeyExpired in getApiKeyAccountId) already ships in api#700, so the key is rejected once its TTL passes.
Where to call: in the headless provision path (after insertSession/insertChat, before connectSandbox), mint a per-run key for the run's accountId; inject rawKey as the sandbox env var $RECOUP_API_KEY (the recoup-api skill reads it for curl); on run end (success or failure) deleteApiKey(keyId). The ~15m expires_at TTL is the defense-in-depth backstop if that delete is missed.
Exact helper removed from api#700, to re-add verbatim (api/lib/keys/mintEphemeralAccountKey.ts):
import{generateApiKey}from"@/lib/keys/generateApiKey";import{hashApiKey}from"@/lib/keys/hashApiKey";import{insertApiKey}from"@/lib/supabase/account_api_keys/insertApiKey";import{PRIVY_PROJECT_SECRET}from"@/lib/const";/** Default lifetime for an ephemeral key: 15 minutes. */exportconstDEFAULT_EPHEMERAL_KEY_TTL_MS=15*60*1000;exporttypeEphemeralAccountKey={rawKey: string;keyId: string};exportasyncfunctionmintEphemeralAccountKey(accountId: string,{ ttlMs =DEFAULT_EPHEMERAL_KEY_TTL_MS, name ="ephemeral:chat-generate"}:
{ttlMs?: number;name?: string}={},): Promise<EphemeralAccountKey>{constrawKey=generateApiKey("recoup_sk");constkeyHash=hashApiKey(rawKey,PRIVY_PROJECT_SECRET);constexpiresAt=newDate(Date.now()+ttlMs).toISOString();const{ data, error }=awaitinsertApiKey({
name,account: accountId,key_hash: keyHash,expires_at: expiresAt,});if(error||!data){thrownewError(`Failed to mint ephemeral api key: ${error?.message??"no row returned"}`);}return{ rawKey,keyId: data.id};}
The insertApiKey change is one optional field: destructure expires_at from the Insert type and pass it through to .insert({ ... expires_at }) (api/lib/supabase/account_api_keys/insertApiKey.ts).
Done when: a POST /api/chat/generate (x-api-key) starts a workflow run that persists assistant messages and performs the report's side effects; response is { runId }. The sandbox receives $RECOUP_API_KEY set to a freshly-minted, single-account recoup_sk_ key; that key is deleted on run end; and a key whose expires_at has passed returns 401 from getApiKeyAccountId (covered by api#700's tests).
api: retire the OpenClaw bridge — removed the prompt_sandbox MCP tool, processCreateSandbox's prompt mode, triggerPromptSandbox, and the POST /api/sandboxes prompt/runId path. → PR api#705 ✅ merged + promoted to main/prod 2026-06-24 — see Done. Correction to the earlier note: the SYSTEM_PROMPT/getGeneralAgent stack is not dead — it's live via Slack chat + the inbound email responder — so the prompt_sandbox prompt references weren't cosmetic; Telegram bot - missing errors #705 also cleaned SYSTEM_PROMPT + the create_knowledge_base description so live agents aren't told to call a removed tool.
Done when: nothing in api calls tasks.trigger("run-sandbox-command", ...); grep is clean.
tasks: make generateChat fire-and-forget — stop awaiting/saving the completion; customer-prompt-task only triggers the run. → PR tasks#152 ✅ merged 2026-06-24 to main (39b877da) — see Done.generateChat now POSTs /api/chat/runs, consumes the 202 { runId } (logs it, returns it), and does NOT await generation; customer-prompt-task returns after the kickoff.
Done when:customer-prompt-task returns after kicking off generation; no generation logic remains in the task.
Open — Phase 2 (LAST, only after the unified path is stable on prod)
Delete the OpenClaw layer from tasks. Remove run-sandbox-command registration + helpers: runSandboxCommandTask.ts, installOpenClaw.ts, onboardOpenClaw.ts, setupOpenClaw.ts, runOpenClawAgent.ts, runSetupSandboxSkill.ts, runSetupArtistSkill.ts, ensureSetupSandbox.ts, installSkill.ts, consts.OPENCLAW_DEFAULT_MODEL, and git/README/org-repo helpers used only by this task; drop related tests.
⚠️ Correction (2026-06-23):installOpenClaw / onboardOpenClaw / setupOpenClaw / runOpenClawAgent / installSkill are shared with codingAgentTask, setupSandboxTask, and the pulse path — not exclusive to run-sandbox-command. This phase safely deletes only runSandboxCommandTask.ts + its exclusive helpers (runSetupSandboxSkill, runSetupArtistSkill, ensureSetupSandbox), OPENCLAW_DEFAULT_MODEL, and the api bridge triggerPromptSandbox. The shared OpenClaw helpers stay until those other tasks are migrated/removed separately.
Gate: no run-sandbox-command runs observed in prod for a stable window after the unified path ships.
Done when:run-sandbox-command + its exclusive helpers no longer appear in tasks (grep clean); tasks redeployed.
Architecture decisions
Async generation converges on runAgentWorkflow — the production interactive architecture — not a new bespoke workflow. Eliminates the dual (legacy ToolLoopAgent vs. workflow) architecture instead of maintaining both.
Scheduling stays in Trigger.dev; generation moves to api.customer-prompt-task + createSchedule/updateSchedule remain as light orchestration; no agent/generation code stays in tasks.
The service x-api-key is never forwarded into model-driven bash. The headless path mints a short-lived, account-scoped token (mirrors the interactive path's recoupAccessToken), preserving the exfiltration boundary documented at handleChatWorkflowStream.ts:148-152.
The prompt_sandbox → run-sandbox-command bridge is removed, not repointed. The unified agent gets native sandbox tools in-workflow, so no offloaded sandbox task is needed.
Accepted regressions / tradeoffs
Composio tools dropped for scheduled generation (Sheets/Drive/Docs/TikTok were in the legacy MCP+Composio set). Acceptable for reports; re-add as native tools if a scheduled task needs them.
/api/chat/generate returns { runId }, not the completion. Acceptable — the sole caller only logged the body. Any future synchronous consumer must poll the run instead.
Source references
New architecture: api/app/lib/workflows/runAgentWorkflow.ts, runAgentStep.ts; tools api/lib/agent/buildAgentTools.ts; entry api/lib/chat/handleChatWorkflowStream.ts.
Legacy path being migrated: api/lib/chat/handleChatGenerate.ts, api/lib/chat/setupToolsForRequest.ts, api/lib/agents/generalAgent/getGeneralAgent.ts.
Tracking issue for converging the async / scheduled chat-generation path onto the same durable Vercel Workflow + native-sandbox architecture that interactive
/api/chatalready runs in prod, and retiring the OpenClawrun-sandbox-commandTrigger.dev task. Scheduling stays in Trigger.dev; generation/agent work moves intoapi.Goal
Async chat generation runs on the same
runAgentWorkflowdurable workflow as interactive/api/chat— an AI SDK agent (streamTexton@ai-sdk/gateway) with native in-sandbox tools (bash/read/write/edit/grep/glob) executing inside a Vercel Workflow. Concretely, when done:POST /api/chat/generateprovisions/attaches a session+sandbox andstart()srunAgentWorkflowinstead of running a synchronousToolLoopAgent(api/lib/chat/handleChatGenerate.ts).prompt_sandboxMCP tool →processCreateSandbox→triggerPromptSandbox→run-sandbox-commandbridge is gone — the agent has native sandbox tools in-workflow.run-sandbox-commandtask are deleted fromtasks.customer-prompt-task+createSchedule/updateSchedule); no generation logic remains intasks.PRs (updated 2026-06-24)
account_api_keys.expires_at(ephemeral-key TTL)mainaccount_api_keys.expires_atinx-api-keyauth (getApiKeyAccountId)testmain/prod 2026-06-24 — see DonebuildRunAgentInput()(behavior-preserving)testmain/prod 2026-06-24 — see DonePOST /api/chat/runs(202{ runId, chatId, sessionId }) +GET /api/chat/runs/{runId}status contractmain/api/chat/runs→ provision + mint +start(runAgentWorkflow)testmain/prod 2026-06-24 — see Doneprompt_sandbox→run-sandbox-commandbridgetestmain/prod 2026-06-24 — see DonegenerateChatfire-and-forget (consume 202)mainmain— see Done✅ Functional migration COMPLETE (2026-06-24). All seven implementation PRs are merged and on prod: the re-point (api#704), the OpenClaw bridge retirement (api#705), and the tasks fire-and-forget cutover (tasks#152). The scheduled path now runs entirely on
runAgentWorkflowwith native sandbox tools + a per-run ephemeral key (mint→inject→authenticate→revoke, verified end-to-end). Nothing triggersrun-sandbox-commandanymore. Only Phase 2 remains — deleting the now-dormant OpenClaw code (install/onboard/gateway/skills/model-config + therun-sandbox-commandtask) from thetasksrepo — intentionally gated on the unified path proving stable in prod first. Two non-blocking fast-follows surfaced in testing: (1) the sandbox agent has no email-sending capability yet; (2)RECOUP_ACCESS_TOKENcarries an API key but is named/documented as a JWT, so therecoup-apiskill should use thex-api-keyheader.Done
database#36 — add
account_api_keys.expires_at(ephemeral-key TTL).✅ Merged 2026-06-24 to
main(merge commitfada52e;databaseis single-branch — notest/mainsplit). First PR in the docs→database→api→tasks order.Adds a nullable
expires_at timestamptztopublic.account_api_keysviaADD COLUMN IF NOT EXISTS— additive, backward-compatible, no data migration.NULL= never expires, so all existing long-lived keys are unaffected; a columnCOMMENTdocuments the semantics and points back here. Unblocks api#700, which enforces the TTL ingetApiKeyAccountId(rejecting expired keys) as defense-in-depth behind the per-run delete-on-completion.Verified: merged migration
supabase/migrations/20260623000000_add_expires_at_to_account_api_keys.sqlpresent onmainatfada52e(19 additions, single ALTER + COMMENT, no down-migration needed). Applying the column to the live/target DB is the operational step that gates the queued re-point PR (above).api#700 — enforce
account_api_keys.expires_atinx-api-keyauth.✅ Merged 2026-06-24 to
test(merge commit03e0ef5), then promoted tomain/prod via api#702 (test→main, merge commit6b62b74, 2026-06-24); enforcement confirmed live onmainatgetApiKeyAccountId.ts:53. Scope: enforcement only —isApiKeyExpired(pure TTL check;NULL/unparseable = never expires) wired intogetApiKeyAccountId, which now returns 401 for a key past itsexpires_at. Minting was deliberately removed (see scope-correction note above) and moves to the re-point item below.Verified end-to-end on the preview deployment (
api-git-feat-ephemeral-account-api-keys-recoup.vercel.app, commit9302dde) againstGET /api/accounts/id, by creating one realrecoup_sk_key inaccount_api_keys(prod DBgodremdqwajrwazhbrue, which the preview reads;expires_atcolumn confirmed present) and varying onlyexpires_at:NULL→ 200, future (+1h) → 200, past (−1h) → 401 (deterministic), row deleted → 401. The future→past flip on the same key isolatesisApiKeyExpiredas the cause; long-lived/NULL-expiry keys are unaffected. Test results comment. (Note: the preview'sPRIVY_PROJECT_SECRETdiffers from local, so the stored hash must be computed with the preview secret.)api#701 — extract shared
buildRunAgentInput()(behavior-preserving).✅ Merged 2026-06-24 to
test(merge commit78bd71b), then promoted tomain/prod via api#703 (test→main, merge commit0c94809, 2026-06-24);lib/chat/buildRunAgentInput.tsconfirmed present onmain. Extracts theRunAgentWorkflowInputconstruction out ofhandleChatWorkflowStreaminto one builder (repo ids + recoup org id both derived fromclone_urlinside it — single source of truth) so the interactive and headless callers stay in lockstep. The interactive path (handleChatWorkflowStream.ts:124) is the live consumer; the headless re-point PR will be the second.Verified end-to-end on the preview (
api-git-feat-build-run-agent-input-recoup.vercel.app, head5ee07e07) by driving the real interactive flow with a Privy Bearer token:POST /api/sessions→ 200;POST /api/sandbox→ 200 (ready 5.6s);POST /api/chat→ 200text/event-stream,x-workflow-run-id: wrun_01KVWZNM82NA7XKNEWWHG8VPHJ, streamed assistant textPR701 OK(model followed the instruction exactly;anthropic/claude-haiku-4.5), 1 assistant message persisted. Proves the builder yields aRunAgentWorkflowInputthe workflow accepts and the agent streams a real response — behavior-preserving. Test session/chat/message deleted afterward. Test results comment.docs#249 —
POST /api/chat/runscontract (the docs lead the api).✅ Merged 2026-06-24 to
main(merge commit58f442a;docsis single-branch). First in the docs→api→tasks order.Renamed
POST /api/chat/generate→POST /api/chat/runs(REST: it creates a run, not agenerateverb) and changed the contract from a synchronous200completion to202 { runId, chatId, sessionId }+ aLocationheader — so a caller can retrieve the result (GET /api/chat/{chatId}/stream, or the persisted messages) instead of fire-and-forget-only. AddedGET /api/chat/runs/{runId}(status snapshot —ChatRunStatusResponse; api implementation deferred to a follow-up). Trimmed the request body toprompt | messages, artistId, model(dropped legacyexcludeTools/roomId/topicto match/api/chat). Bidirectional cross-links added across the four chat endpoints./api/chat/generateremoved (no alias).Verified:
api-reference/chat/runs.mdxpresent onmainat58f442a; OpenAPI (research.json) carries/api/chat/runs+/api/chat/runs/{runId}. api#704/Telegram bot - missing errors #705/tasks#152 were re-pointed to/api/chat/runsto fulfill this contract.api#704 — re-point
POST /api/chat/runsontorunAgentWorkflow(the keystone).✅ Merged 2026-06-24 to
test(merge commit2209b7de), then promoted tomain/prod via api#706 (test→main, merge commit6abaad68, 2026-06-24);app/api/chat/runs/route.ts, the{runId}status route, and thelib/chat/runs/rename all confirmed onmain. (Telegram bot - new conversations - hide test emails #706 needed a one-file conflict resolution inbuildRunAgentInput.ts— took thetestside withephemeralKeyId, sincemainonly lagged behind.) This is the consumer that re-adds the minting deferred from api#700.Provisions a headless session + active sandbox (shared
createSessionWithInitialChat+connectSandbox+markSessionSandboxActive), mints a per-run ephemeralrecoup_sk_key → injects asrecoupAccessToken(never the service key) → threadsagentContext.ephemeralKeyId→start(runAgentWorkflow)→ returns 202{ runId, chatId, sessionId }+Location. The workflow'sfinallyrevokes the key on run end (deleteEphemeralKeyStep); the ~15m TTL is the backstop. Also implementsGET /api/chat/runs/{runId}(status). Scope evolved in review:/generate→/runsrename, dropped legacyexcludeTools/roomId/topic, SRP/DRY extractions shared with the interactive/api/sessions+/api/sandboxhandlers, andlib/chat/generate/→lib/chat/runs/rename.Verified end-to-end on the preview across the review iterations (latest
10a727ce):POST /api/chat/runs→ 202;GET …/{runId}→running→completed;404unknown run;401no-auth; the agent ran real nativebashtool calls (e.g.echo rename-verified→ stdoutrename-verified) observable via the persisted chat bychatId; ephemeral key auto-revoked (0 left) each run; all test artifacts cleaned up. Observability test comment.api#705 — retire the OpenClaw
prompt_sandbox→run-sandbox-commandbridge.✅ Merged 2026-06-24 to
test(merge commit4c09cf06), then promoted tomain/prod via api#707 (test→main, merge commit83a269ea);registerPromptSandboxTool.tsconfirmed gone frommain.Removed the
prompt_sandboxMCP tool + its registration,triggerPromptSandbox(therun-sandbox-commandtrigger),processCreateSandbox's prompt mode, and thePOST /api/sandboxesprompt/runId path (nowaccount_id-only). Folded in the prompt cleanup once the audit showed the legacygetGeneralAgentstack is live (Slack chat + inbound email), not dead: stripped theprompt_sandbox"Sandbox-First" section fromSYSTEM_PROMPTand the(use prompt_sandbox for those)pointer fromcreate_knowledge_base, with guard tests on both. Behavior note: Slack + email agents lose the OpenClaw sandbox tool — fine, since OpenClaw is the failing component being removed; those two still run on the legacygetGeneralAgentstack (notrunAgentWorkflow), so that stack can't be fully deleted yet (out of scope here).Verified on the preview (
65dd8d05): live MCPtools/list(39 tools) showsprompt_sandboxremoved and no sandbox tools remain;create_knowledge_base's live description no longer mentions it;POST /api/sandboxeswith a legacy{prompt}returns{sandboxes:[…]}with norunId(prompt ignored). Test results comment.tasks#152 —
generateChatfire-and-forget (the final cutover PR).✅ Merged 2026-06-24 to
main(merge commit39b877da).generateChatnow POSTs${NEW_API_BASE_URL}/api/chat/runs, consumes the 202{ runId }(logs/returns it for observability only), and does not await generation — persistence + side effects run server-side in the durable workflow.customer-prompt-taskreturns right after the kickoff. With this merged, the scheduled path runs entirely onrunAgentWorkflowand nothing triggersrun-sandbox-command.Verified end-to-end through the live path (local
tasksTrigger.dev dev →customer-prompt-task→/api/chat/runsprod → durable run): task completed in 6.7s without awaiting, kickoff returned 202{ runId: wrun_01KVXY569… }. A diagnostic run confirmed the ephemeral-key architecture works: the sandbox agent's shell got$RECOUP_ACCESS_TOKEN= arecoup_sk_key (len 53), authenticated to the Recoup API (/api/accounts/id→ 200, correctaccountId) via thex-api-keyheader, and the key was revoked on run end (0ephemeral:*keys linger). Env-matching holds: the prod-minted token is valid against prod (200) and rejected bytest-recoup-api(401). Test results comment.Known fast-follow (non-blocking): the new sandbox agent has no email-sending capability yet (the "Daily Hello World Email" test run completed but reported no email tool), and
RECOUP_ACCESS_TOKENcarries an API key while its name/JSDoc imply a Privy JWT — align so therecoup-apiskill usesx-api-key.Current failure (context)
run-sandbox-command, prod projectRecoup-Chat(proj_pxwxehzmqaxylqhhkomn). Representative run finished 2026-06-22 13:02 UTC,exitCode: 1.tasks/src/sandboxes/runSetupSandboxSkill.ts:23("Failed to set up sandbox via OpenClaw"), reached viaensureSetupSandbox.ts:34←runSandboxCommandTask.ts:68.GatewayClientRequestError: ... All models failed (2): vercel-ai-gateway/anthropic/claude-opus-4.6: Unknown model .... The model id is valid in the live gateway catalog, so this is a runtime model/catalog-resolution failure in the OpenClaw gateway daemon (candidate causes: gateway daemon not receivingAI_GATEWAY_API_KEY; onboard-default model vs. allowlist mismatch). Root cause was not conclusively pinned — which is itself the argument for removing OpenClaw rather than patching it.Open — immediate (this week, out-of-band)
✅ Shipped 2026-06-22 — pulled all five
/api/research/*endpoints live (HTTP 200, real data), composed the report in the task's structure, and emailed it fromagent@recoupable.com(Resend accepted, message id35ca1a58-38e6-4cbe-b829-5467f9e8ad63). Details in the closure comment. Out-of-band; not part of the migration's acceptance.Open — design spikes (derisk before implementation)
recoup-apicalls.recoup-apiviabash+curlusing$RECOUP_ACCESS_TOKEN; the interactive path forwards a short-lived Privy JWT and deliberately does not forward the long-lived service key into model-driven bash (api/lib/chat/handleChatWorkflowStream.ts:148-152). The scheduled caller only holds the servicex-api-key.recoup_sk_key (not a new JWT). Privy JWTs can't be issued server-side (@privy-io/nodeis verify-only), so we reuse the existing api-key primitive: addaccount_api_keys.expires_at, mint a ~15m-TTL single-account key per run, inject as$RECOUP_API_KEY, delete on run end; auth rejects expired keys. Reproduces the interactive trust profile (short-lived single-account bearer in the sandbox). PRs: database#36 (column), api#700 (expiry enforcement only — minting deferred to the re-point PR; see the scope-correction note above). Decision comment.runAgentWorkflowrequires a live session with an active sandbox beforestart()(api/lib/chat/handleChatWorkflowStream.ts:67→ 400 "Sandbox not initialized"); the legacy path lazily span one up viaprompt_sandbox, so the scheduled path pre-provisions nothing.ensurePersonalRepo→insertSession(buildSessionInsertRow) →insertChat, thenconnectSandbox+updateSession(sandbox.getState()/buildActiveLifecycleUpdate) untilisSandboxActive. Scheduled caller hasaccountId(+ optionalartistId/roomId) and mints the session+chat. Implemented in the re-point PR.curl.buildAgentTools(api/lib/agent/buildAgentTools.ts) ships native sandbox/file tools only — nosend_email, no research, no Composio. The legacy generate path got those via MCP + Composio (api/lib/chat/setupToolsForRequest.ts:20-22). Moving the report onto the new agent loses email unless addressed.recoup-api-skill — keeps the broad recoup-api surface (research + email) without enumerating native tools; depends on the ephemeral key above for$RECOUP_API_KEY.Open — implementation (in merge order)
Documentation-driven; sequence docs → database → api → tasks, honoring hard deps. (Prereqs
buildRunAgentInputextraction [api#701] +expires_atenforcement [api#700] are done + on prod — see Done.)POST /api/chat/runsto provision +start(runAgentWorkflow)and return202 { runId, chatId, sessionId }. → PR api#704 ✅ merged totest(prod promotion pending) — see Done. Re-addsmintEphemeralAccountKey+insertApiKeywriter; newvalidateGenerateRequest+provisionGenerateSession; mints key → injects asrecoupAccessToken→ threadsagentContext.ephemeralKeyId;runAgentWorkflow'sfinallydeletes the key on run end (deleteEphemeralKeyStep). Preview test:POST /api/chat/generate→ 202{ runId: wrun_01KVX2RBB3JDZFA0131B3RT1QS }→ assistant messagePR704 OKpersisted → ephemeral key revoked. Contract = docs#249.tasks/src/recoup/generateChat.ts, uses the response only for logging (textPreview/usage) — persistence + email happen server-side. (tasks side: tasks#152.)lib/keys/mintEphemeralAccountKey.ts+ theinsertApiKeyexpires_atwriter param, then call them here as part of the headless provision step. Expiry enforcement (isApiKeyExpiredingetApiKeyAccountId) already ships in api#700, so the key is rejected once its TTL passes.insertSession/insertChat, beforeconnectSandbox), mint a per-run key for the run'saccountId; injectrawKeyas the sandbox env var$RECOUP_API_KEY(therecoup-apiskill reads it forcurl); on run end (success or failure)deleteApiKey(keyId). The ~15mexpires_atTTL is the defense-in-depth backstop if that delete is missed.api/lib/keys/mintEphemeralAccountKey.ts):insertApiKeychange is one optional field: destructureexpires_atfrom theInserttype and pass it through to.insert({ ... expires_at })(api/lib/supabase/account_api_keys/insertApiKey.ts).POST /api/chat/generate(x-api-key) starts a workflow run that persists assistant messages and performs the report's side effects; response is{ runId }. The sandbox receives$RECOUP_API_KEYset to a freshly-minted, single-accountrecoup_sk_key; that key is deleted on run end; and a key whoseexpires_athas passed returns 401 fromgetApiKeyAccountId(covered by api#700's tests).prompt_sandboxMCP tool,processCreateSandbox's prompt mode,triggerPromptSandbox, and thePOST /api/sandboxesprompt/runId path. → PR api#705 ✅ merged + promoted tomain/prod 2026-06-24 — see Done. Correction to the earlier note: theSYSTEM_PROMPT/getGeneralAgentstack is not dead — it's live via Slack chat + the inbound email responder — so theprompt_sandboxprompt references weren't cosmetic; Telegram bot - missing errors #705 also cleanedSYSTEM_PROMPT+ thecreate_knowledge_basedescription so live agents aren't told to call a removed tool.apicallstasks.trigger("run-sandbox-command", ...); grep is clean.generateChatfire-and-forget — stop awaiting/saving the completion;customer-prompt-taskonly triggers the run. → PR tasks#152 ✅ merged 2026-06-24 tomain(39b877da) — see Done.generateChatnow POSTs/api/chat/runs, consumes the 202{ runId }(logs it, returns it), and does NOT await generation;customer-prompt-taskreturns after the kickoff.customer-prompt-taskreturns after kicking off generation; no generation logic remains in the task.Open — Phase 2 (LAST, only after the unified path is stable on prod)
tasks. Removerun-sandbox-commandregistration + helpers:runSandboxCommandTask.ts,installOpenClaw.ts,onboardOpenClaw.ts,setupOpenClaw.ts,runOpenClawAgent.ts,runSetupSandboxSkill.ts,runSetupArtistSkill.ts,ensureSetupSandbox.ts,installSkill.ts,consts.OPENCLAW_DEFAULT_MODEL, and git/README/org-repo helpers used only by this task; drop related tests.installOpenClaw/onboardOpenClaw/setupOpenClaw/runOpenClawAgent/installSkillare shared withcodingAgentTask,setupSandboxTask, and the pulse path — not exclusive torun-sandbox-command. This phase safely deletes onlyrunSandboxCommandTask.ts+ its exclusive helpers (runSetupSandboxSkill,runSetupArtistSkill,ensureSetupSandbox),OPENCLAW_DEFAULT_MODEL, and the api bridgetriggerPromptSandbox. The shared OpenClaw helpers stay until those other tasks are migrated/removed separately.run-sandbox-commandruns observed in prod for a stable window after the unified path ships.run-sandbox-command+ its exclusive helpers no longer appear intasks(grep clean);tasksredeployed.Architecture decisions
runAgentWorkflow— the production interactive architecture — not a new bespoke workflow. Eliminates the dual (legacyToolLoopAgentvs. workflow) architecture instead of maintaining both.api.customer-prompt-task+createSchedule/updateScheduleremain as light orchestration; no agent/generation code stays intasks.x-api-keyis never forwarded into model-driven bash. The headless path mints a short-lived, account-scoped token (mirrors the interactive path'srecoupAccessToken), preserving the exfiltration boundary documented athandleChatWorkflowStream.ts:148-152.prompt_sandbox→run-sandbox-commandbridge is removed, not repointed. The unified agent gets native sandbox tools in-workflow, so no offloaded sandbox task is needed.Accepted regressions / tradeoffs
/api/chat/generatereturns{ runId }, not the completion. Acceptable — the sole caller only logged the body. Any future synchronous consumer must poll the run instead.Source references
api/app/lib/workflows/runAgentWorkflow.ts,runAgentStep.ts; toolsapi/lib/agent/buildAgentTools.ts; entryapi/lib/chat/handleChatWorkflowStream.ts.api/lib/chat/handleChatGenerate.ts,api/lib/chat/setupToolsForRequest.ts,api/lib/agents/generalAgent/getGeneralAgent.ts.api/lib/mcp/tools/sandbox/registerPromptSandboxTool.ts,api/lib/sandbox/processCreateSandbox.ts,api/lib/trigger/triggerPromptSandbox.ts,tasks/src/tasks/runSandboxCommandTask.ts,tasks/src/sandboxes/*(OpenClaw helpers).tasks/src/tasks/customerPromptTask.ts,tasks/src/recoup/generateChat.ts,api/lib/trigger/createSchedule.ts.api):next.config.ts(withWorkflow),workflow@4.2.4, existingapi/app/workflows/*.