At a glance
Verified against live prod (chat.think2earn.com), EC2
i-076d244b62ec4d8e8 (think2earn-prod), account
995772916887, region us-east-1, and stack
think2earn-harness (handoff 2026-07-08; E2E 33/33, isolation probes 18/18).
| Layer | Production default | Live canary (allowlisted) | Next / not yet |
|---|---|---|---|
| Public URL | chat.think2earn.com (Cloudflare Tunnel → EC2 :8080) |
Static SPA on CDN + API split | |
| Control plane | Single EC2: Node/Express API + Vite SPA, billing, journal owner | Serverless API (Lambda); retire EC2 for app traffic | |
Default chat (chatMode: simple) |
Bedrock ConverseStream tool loop on EC2; AgentCore Browser / CI / Memory / Exa Gateway | Same loop, eventually off EC2 | |
| Agent-mode turns | AgentCore Runtime SSE path on EC2 (byte-identical for non-allowlisted users) | Fargate async: 202 + journal poll; one ECS task per turn | Global THINK2EARN_HARNESS_RUNTIME=fargate; decommission AgentCore chat path |
| Isolation model | App-layer tenancy; shared Think2earnHostRole for AgentCore |
Per-turn vended IAM scoped to users/<userId>/… + turn keys |
All agent turns on Fargate isolation |
| Turn journal | In-memory hot path on EC2 + optional DynamoDB table think2earn_turn_journal (Fargate path writes here) |
Journal fully durable for all modes; EC2 memory not required | |
| Dispatch / workers | — | Live: SQS kick, 4 Lambdas, ECS Fargate taskdef think2earn-harness-claude-sdk:4 |
SOCI + slim image; optional warm linger |
| Auth / billing | Cognito + native auth + app JWT; Stripe credits — unchanged | ||
| Deploy | App: PR → main → deploy.yml (SSM + rsync) or deploy-via-ssm.sh. Env file excluded from rsync. |
Harness: sam deploy + ECR image tag = git SHA |
|
Default Production control plane
One long-lived EC2 host still fronts the entire web product. The Fargate plane does not replace the API — it only executes allowlisted agent-mode turns.
flowchart TB
subgraph users [Users]
Browser[Browser]
end
subgraph edge [Cloudflare]
CF_DNS[DNS]
CF_Tunnel[Tunnel to origin]
end
subgraph ec2 [Control plane EC2 think2earn-prod]
Cloudflared[cloudflared]
NodeApp[Node Express API plus Vite SPA]
Journal[Turn journal memory plus DDB]
Router[effectiveHarnessRuntime]
IAMHost[Think2earnHostRole]
end
subgraph agentcore_path [Agent path default]
Runtime[AgentCore Runtime microVM]
end
subgraph fargate_path [Agent path canary]
Kick[SQS turn-kick]
Dispatch[dispatch-turn Lambda]
Task[ECS Fargate worker]
Vend[vend-credentials Lambda]
Finalize[finalize-turn Lambda]
end
subgraph tools [Shared tool services]
Converse[Bedrock ConverseStream]
CI[Code Interpreter]
BrowserAC[Browser CDP]
Memory[AgentCore Memory]
Gateway[Exa Gateway]
end
subgraph data [AWS data]
DDB[(DynamoDB core plus journal plus dispatch)]
S3[(S3 artifacts and staging)]
end
Browser --> CF_DNS --> CF_Tunnel --> Cloudflared --> NodeApp
NodeApp --> Journal
NodeApp --> Router
NodeApp --> DDB
NodeApp --> S3
NodeApp --> IAMHost
IAMHost --> Converse
IAMHost --> CI
IAMHost --> BrowserAC
IAMHost --> Memory
IAMHost --> Gateway
Router -->|not allowlisted agent mode| Runtime
Router -->|allowlisted agent mode| Kick --> Dispatch --> Task
Task --> Vend
Task --> Journal
Task --> S3
Task --> Converse
Task -.-> Finalize
Request paths that every user hits
Default chat — simple mode
chatModedefaults tosimple- Browser POST → SSE on EC2
- Bedrock ConverseStream + native tool loop (max tool rounds)
- Tools when env IDs set:
run_code,browse_web,web_search,update_plan - AgentCore Browser / CI / Memory / Gateway called from the host role
- Screenshots and run_code files persisted to S3 as conversation artifacts
- Does not use Fargate or the allowlist
Agent mode — non-allowlisted (majority)
THINK2EARN_HARNESS_RUNTIME=agentcoredefault- User not in
THINK2EARN_FARGATE_USER_ALLOWLIST - SSE 200 path on EC2 via AgentCore Runtime / Claude Agent SDK runtime image
- No dispatch row, no RunTask, no vend call
- Flag-off regression proven: still byte-identical AgentCore SSE
Characteristics (still true)
- Single API host — API, SPA, billing, SSE relay, and simple-mode tools share one EC2 process.
- Shared host IAM for AgentCore tools — tenants share
Think2earnHostRole; app-layer org/user/conversation isolation. - E8 detached turns — client disconnect no longer aborts; Stop uses explicit cancel; reattach via journal +
/active-turn. - Env is not the repo
.env— systemd loadsthink2earn-management.env(+ local overrides); deploy rsync excludes env. - Architecture static site — this page is separate on Cloudflare Pages (
architecture.think2earn.com); product SPA is still served from EC2.
Canary Fargate worker plane (live)
Option B from the Jul 2026 architecture decision is partially shipped: AgentCore is no longer the only agent executor. The worker plane runs each allowlisted agent turn in its own throwaway container; isolation is IAM math (vended session policy), not process trust.
What is live vs what is not
- Live: SAM stack
think2earn-harness, 4 Lambdas, SQS kick + DLQ, DynamoDB dispatch, ECR worker image, ECS cluster/taskdef, vend API, finalize + settlement, sweeper, host IAM patch, canary env flags. - Live for canary only: agent-mode
POST /chats/:id/messages→202 { mode: "fargate-async" }+ client journal polling. - Not live: global Fargate cutover; EC2 retirement; SPA on CloudFront; full serverless control plane; SOCI-accelerated pulls (scaffolded, image still ~1365 MB without SOCI).
Lambdas (one job each)
| Function | Trigger | Responsibility |
|---|---|---|
dispatch-turn |
SQS think2earn-turn-kick |
Claim QUEUED → DISPATCHING (capacity: 3/user, 60 global), RunTask → RUNNING |
vend-credentials |
HTTP API /internal/vend (SigV4, task identity) |
6-step verify → AssumeRole with compact session policy + userId tag; refreshable ~50 min |
finalize-turn |
EventBridge on ECS task STOP | Terminal dispatch status (DONE / FAILED / CANCELLED) + settlement (assistant message, artifact dock rows) |
sweeper |
1/min schedule | Kick queued, reconcile stale DISPATCHING/RUNNING vs ECS (5 min heartbeat), hard-stop cost cap |
Worker container
- Image:
…/think2earn-harness-node:efea455(taskdef revision:4) - Near-zero task role; real data access only after vend
- Claude Agent SDK with
bypassPermissions— isolation is the vended IAM scope (probes: 18/18) - Stages nothing from conversations table: EC2 stages
input.jsonunder the user S3 prefix first - Env contract: IDs and pointers only — no prompt, no AWS keys in
RunTaskoverrides - Journal events as primary telemetry (successful workers often log nothing to CloudWatch)
Temporary Canary user allowlist
Routing is decided in the EC2 app by effectiveHarnessRuntime(userId)
(src/config.ts). Global flag stays agentcore; only listed user IDs take Fargate.
Production canary config (verified on EC2 process environ)
THINK2EARN_HARNESS_RUNTIME=agentcore— default for everyoneTHINK2EARN_FARGATE_USER_ALLOWLIST=user_d5418965-e879-492f-bb98-af64ec605893,e4787408-a061-7054-308a-b910301a96c6- Both IDs are the same person (Carol), not two different canary users:
user_d5418965-…— native-auth userId (carol@think2earn.com)e4787408-…— Google-federated Cognito sub (acalincarol@gmail.com)
- Matcher uses
context.user.userId(native =user_<uuid>, Cognito = raw sub). Both must be listed if both login methods are used — fixed in PR #146 after #145 only listed the Cognito sub. - Mirrored in
scripts/render-ec2-env.sh(env is not deployed by rsync).
flowchart TD
POST["POST /chats/:id/messages agent mode"]
Check{"effectiveHarnessRuntime userId"}
Global{"HARNESS_RUNTIME = fargate?"}
Allow{"userId in FARGATE_USER_ALLOWLIST?"}
AC["AgentCore SSE path 200"]
FG["startTurn: stage S3 + journal META + dispatch QUEUED + SQS kick"]
R202["202 mode fargate-async"]
Poll["Client polls GET .../turns/:requestId/events"]
POST --> Check
Check --> Global
Global -->|yes| FG
Global -->|no| Allow
Allow -->|yes| FG
Allow -->|no| AC
FG --> R202 --> Poll
How to change / roll back the canary
| Action | How |
|---|---|
| Add a canary user | Append both of their identities if they use native + federated login; render env; SSM push; restart service |
| Turn-level rollback | Empty THINK2EARN_FARGATE_USER_ALLOWLIST; keep runtime agentcore; render + push + restart |
| Freeze all new Fargate work | SSM /think2earn/dispatch-enabled=false |
| Global cutover (future) | THINK2EARN_HARNESS_RUNTIME=fargate — allowlist becomes moot |
Canary path Fargate turn lifecycle
sequenceDiagram
participant C as Client
participant E as EC2 app
participant S3 as S3 staging
participant J as turn_journal
participant D as turn_dispatch
participant Q as SQS kick
participant L as dispatch-turn
participant T as Fargate worker
participant V as vend-credentials
participant F as finalize-turn
C->>E: POST messages agent mode allowlisted
E->>S3: put input.json under users/userId
E->>J: META runtime fargate
E->>D: row QUEUED
E->>Q: SendMessage kick
E-->>C: 202 requestId mode fargate-async
C->>J: poll events
Q->>L: kick
L->>D: claim QUEUED to DISPATCHING
L->>T: RunTask
L->>D: RUNNING
T->>V: vend scoped creds
V-->>T: temporary credentials
T->>J: stream events artifacts done
T->>S3: artifact sweep
Note over T,F: task STOP EventBridge
F->>D: DONE or FAILED or CANCELLED
F->>E: settlement message plus dock rows
Cancel semantics
POST …/turns/:requestId/cancel- QUEUED: synthetic done; no RunTask
- DISPATCHING / RUNNING: set journal
META.cancelRequested; worker polls ~5 s, aborts, truncated sweep - Client poll terminal for cancelled:
409 {"error":"truncated"}(not a hang) - billable=false on truncated done events
Capacity & safety
- Concurrency derived from dispatch status (no leaky counters): 3 concurrent / user, 60 global
- 4th concurrent turn stays
QUEUEDwithqueuePosition - Sweeper reconciles stuck rows; DLQ alarms on
think2earn-turn-kick-dlq - Budget alarm:
think2earn-harness-monthly$200/mo, >80% ACTUAL → operator email - Kill switch: SSM
/think2earn/dispatch-enabled
Compute paths compared
| Workload | Who | Where it runs today |
|---|---|---|
| Quick chat + tools | All users | EC2 → Bedrock ConverseStream; AgentCore CI / Browser / Gateway from host |
| Agent turn | Non-allowlisted | EC2 SSE → AgentCore Runtime (Claude Agent SDK image / wave1 runtime) |
| Agent turn | Allowlisted canary | EC2 stages + 202 → Fargate worker + vend IAM → journal poll → finalize settlement |
| SSE / journal for simple mode | All users | EC2 holds stream; E8 continues after disconnect; in-memory journal (+ DDB when configured) |
| Fargate journal poll | Canary agent turns | Client polls journal events; journal done is authoritative; dispatch terminal lags ≤2 min |
| User shell / terminal | Entitled workspaces | Still host-side local_terminal / premium workspace path — not the Fargate worker |
Data & state
| State | Store today | Notes |
|---|---|---|
| Conversations, messages, users, billing | DynamoDB (foundation stack tables) | Unchanged entity model |
| Turn journal (SSE / poll replay) | EC2 memory hot path + think2earn_turn_journal |
Fargate path requires journal table; Wave 0 dual-write design |
| Turn dispatch queue of record | think2earn_turn_dispatch |
SQS is only a kick, not the queue of record |
| Fargate input staging | S3 users/<userId>/… input.json |
Worker never reads conversations table |
| Artifacts (images, HTML, files, plans) | S3 + library assets; dock via GET /artifacts |
Simple-mode screenshots + run_code files; Fargate settlement writes dock rows |
| Working plan | conversation.workingPlan via update_plan tool |
No separate store entity |
| Secrets / flags | SSM + EC2 env files; stack params | dispatch-enabled in SSM; canary flags in env |
| Per-turn worker credentials | STS session via vend Lambda | Not in task env; refreshable; packed-policy budget guarded by tests |
Live stack inventory
| Resource | Value |
|---|---|
| Stack | think2earn-harness (us-east-1) |
| ECS cluster | think2earn-harness-cluster |
| Task definition | think2earn-harness-claude-sdk:4 |
| ECR | 995772916887.dkr.ecr.us-east-1.amazonaws.com/think2earn-harness-node |
| Kick queue | https://sqs.us-east-1.amazonaws.com/995772916887/think2earn-turn-kick |
| Vend endpoint | https://pmva7u24hc.execute-api.us-east-1.amazonaws.com/internal/vend |
| Dispatch table | think2earn_turn_dispatch |
| Journal table | think2earn_turn_journal |
| Host IAM patch | Think2earnHostFargatePatch on Think2earnHostRole (dispatch, kick, staging PutObject) |
| Evidence | GitHub issue #124 · handoff docs/handoff-fargate-worker-plane-2026-07-08.md · PRs #118–#154 |
Repo map
Worker plane
sam/template.yamlsam/functions/{dispatch-turn,finalize-turn,sweeper,vend-credentials}/deploy/harness-fargate/(Dockerfile, worker, probes)
EC2 seam
src/harness/startTurn.tssrc/config.ts→effectiveHarnessRuntime- Cancel / poll routes in
src/http/api.ts scripts/render-ec2-env.shcanary block
Next Roadmap from here
- Canary week (now) — real use on allowlisted Carol identities; measure kick→first-event latency, vend errors, DLQ, cost delta.
- W2-D image slimming + SOCI — baseline ~34–37 s to first journal event / ~46–51 s done (haiku); target ≤~30 s first event.
- Linger-window decision — whether follow-up turns reuse a warm task vs pay cold start every time.
- §14 step 7 global cutover —
THINK2EARN_HARNESS_RUNTIME=fargate; allowlist moot; then decommission AgentCore chat path. - Later control-plane migration — SPA on CDN; API off EC2; Cloudflare Tunnel only until that split lands.
Stays on EC2 for now
- Public API + SPA
- Simple-mode Bedrock loop
- Billing / Stripe / Cognito callbacks
- Start-turn staging for Fargate
Already on serverless workers
- Dispatch claim + RunTask
- Credential vending
- Finalize + settlement
- Sweeper / capacity reconcile
Still north-star only
- No always-on API host
- All users on Fargate agents
pub.think2earn.compublish tier- Per-user IAM (deferred)
What changed vs the old architecture page (2026-06-30)
The previous page claimed a pure “current EC2 / target serverless AgentCore Runtime” split and said the turn journal was only in-memory. Several of those claims are now wrong or incomplete after the Fargate worker plane landed.
Agent execution is dual-path, not single-path
Old page: long work = AgentCore Runtime only; target was still AgentCore + Claude Agent SDK microVMs.
Now: decision Option B replaced that target for agent turns — ECS Fargate is the
worker plane, live behind allowlist; AgentCore remains the default until global cutover.
Control plane is not serverless yet
Old page target: “no always-on API EC2” as the near-term north star drawing.
Now: EC2 remains the API/journal/settlement owner by design for this phase. Fargate only executes turns.
Full serverless control plane is still later roadmap, not deployed.
Journal + dispatch tables exist
Old page: journal = EC2 memory ~30 min TTL only.
Now: dual-write journal with DynamoDB table; Fargate uses durable journal events + dispatch state machine
as the queue of record (SQS is a kick only).
Credentials are vended, not static env
Workers hold near-zero permissions and call a vend Lambda for refreshable, per-user/per-turn STS sessions (fixes the 1-hour role-chaining trap called out in the design review).
Temporary canary allowlist is an explicit production control
Not mentioned on the old page. Production ships with Carol’s two login identities allowlisted; empty the list to roll back without tearing down the stack.
What stayed the same
- Public product URL and Cloudflare Tunnel → EC2 edge path
- Default
chatMode: simple+ Bedrock ConverseStream on the host - Cognito / native auth / app JWT and Stripe credit billing
- Shared AgentCore tool resources (Browser, Code Interpreter, Memory, Exa Gateway) for simple mode
- S3 artifact bucket + conversation artifact dock model
- Account
995772916887, regionus-east-1, prod instancei-076d244b62ec4d8e8
Still out of scope for this phase
- Per-user dedicated EC2 + DCV desktop as the agent runtime
- Standalone CLI binary
- Full integrations OAuth marketplace
- Replacing Exa Gateway with a different search provider (unless product decides later)
- Global Fargate cutover before canary exit criteria