diff --git a/.env.example b/.env.example
index 359eec7..9f7b3d0 100644
--- a/.env.example
+++ b/.env.example
@@ -9,20 +9,30 @@ POSTGRES_PASSWORD=growqr
POSTGRES_DB=growqr
# Clerk auth — get from dashboard.clerk.com → API Keys
-CLERK_SECRET_KEY=sk_test_REPLACE_ME
-CLERK_PUBLISHABLE_KEY=pk_test_REPLACE_ME
+CLERK_SECRET_KEY=clerk_key
+CLERK_PUBLISHABLE_KEY=clerk_publishable_key
-# Anthropic — get from console.anthropic.com → API Keys
-ANTHROPIC_API_KEY=sk-ant-REPLACE_ME
-GROW_AGENT_MODEL=claude-opus-4-7
-SUB_AGENT_MODEL=claude-sonnet-4-6
+# OpenCode Zen LLM gateway — get from opencode.ai/auth
+OPENCODE_API_KEY=sk-REPLACE_ME
+LLM_PROVIDER=opencode
+LLM_BASE_URL=https://opencode.ai/zen/v1
+LLM_MODEL=kimi-k2.6
+GROW_AGENT_MODEL=kimi-k2.6
+SUB_AGENT_MODEL=kimi-k2.6
MAX_AGENT_TOKENS=4096
# Shared secret for actor → backend service calls (rotate in prod)
SERVICE_TOKEN=dev-service-token-REPLACE_ME
+A2A_ALLOWED_KEY=dev-a2a-key
# Rivet Kit engine (self-hosted in docker-compose)
RIVET_ENDPOINT=http://localhost:6420
+RIVET_CLIENT_ENDPOINT=http://127.0.0.1:4000/api/rivet
+
+# Product microservice sub-agent URLs
+INTERVIEW_SERVICE_URL=http://localhost:8007
+ROLEPLAY_SERVICE_URL=http://localhost:8008
+QSCORE_SERVICE_URL=http://localhost:8000
# Per-user container images
GITEA_IMAGE=gitea/gitea:1.22
diff --git a/.gitignore b/.gitignore
index e6749ef..420198d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
node_modules
dist
+.data
.env
.env.local
*.log
diff --git a/README.md b/README.md
index 3652524..c38716a 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ A multi-agent platform where every user gets a private Grow Agent (Rivet Kit act
- **Auth**: Clerk (frontend + backend JWT verification).
- **DB**: Postgres + Drizzle (users, actor registry, container mappings, repos, OpenCode sessions, events).
-- **Actors**: Rivet Kit — `growAgent` per user (master) and `subAgent` (worker), with a real Anthropic Claude tool-use loop.
+- **Actors**: Rivet Kit — `growAgent` per user (master) and `subAgent` (worker), with a real OpenCode Zen / OpenAI-compatible tool-use loop.
- **Per-user containers**: Gitea (memory repo) + OpenCode (workflow execution), spawned via `dockerode`, with admin user + access token bootstrap, ports allocated from a managed pool, lifecycle reconciled on backend boot.
- **Frontend**: Next.js 16 with `@clerk/nextjs` for auth and `rivetkit/client` for direct actor connections + event streaming.
- **Tool surface available to the Grow Agent**: `spawn_sub_agent`, `commit_memory`, `read_memory`, `list_memory`.
@@ -16,7 +16,7 @@ A multi-agent platform where every user gets a private Grow Agent (Rivet Kit act
You need three external accounts before running:
1. **Clerk** — create an app at https://dashboard.clerk.com → copy the publishable + secret keys.
-2. **Anthropic** — create an API key at https://console.anthropic.com.
+2. **OpenCode Zen** — create an API key at https://opencode.ai/auth.
3. **Docker** — Docker Desktop (or any daemon `dockerode` can reach via `/var/run/docker.sock`).
Then:
@@ -24,7 +24,7 @@ Then:
```bash
# Backend env
cp .env.example .env
-# fill in CLERK_SECRET_KEY, CLERK_PUBLISHABLE_KEY, ANTHROPIC_API_KEY
+# fill in CLERK_SECRET_KEY, CLERK_PUBLISHABLE_KEY, OPENCODE_API_KEY
# Frontend env
cd growqr-frontend
@@ -73,7 +73,7 @@ Next.js (3000) ──fetch──▶ /users/bootstrap, /users/me (Hono on 40
▼
Grow Agent actor (one per user)
│
- ├─ Anthropic (Opus 4.7 + tool use)
+ ├─ OpenCode Zen (Kimi K2.6 + tool use)
├─ commit_memory ────▶ Gitea container (per user)
└─ spawn_sub_agent ────▶ OpenCode container (per user)
(multiplexed sessions)
diff --git a/docker-compose.yml b/docker-compose.yml
index 79843f4..ff3459c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -54,12 +54,20 @@ services:
RIVET_ENDPOINT: http://rivet-engine:6420
CLERK_SECRET_KEY: ${CLERK_SECRET_KEY}
CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY}
- ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
- GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-claude-opus-4-7}
- SUB_AGENT_MODEL: ${SUB_AGENT_MODEL:-claude-sonnet-4-6}
+ OPENCODE_API_KEY: ${OPENCODE_API_KEY}
+ LLM_PROVIDER: ${LLM_PROVIDER:-opencode}
+ LLM_BASE_URL: ${LLM_BASE_URL:-https://opencode.ai/zen/v1}
+ LLM_MODEL: ${LLM_MODEL:-kimi-k2.6}
+ GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-kimi-k2.6}
+ SUB_AGENT_MODEL: ${SUB_AGENT_MODEL:-kimi-k2.6}
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token}
+ A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-dev-a2a-key}
+ RIVET_CLIENT_ENDPOINT: ${RIVET_CLIENT_ENDPOINT:-http://127.0.0.1:4000/api/rivet}
GITEA_IMAGE: ${GITEA_IMAGE:-gitea/gitea:1.22}
- OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/sst/opencode:latest}
+ OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/anomalyco/opencode:latest}
+ INTERVIEW_SERVICE_URL: ${INTERVIEW_SERVICE_URL:-http://host.docker.internal:8007}
+ ROLEPLAY_SERVICE_URL: ${ROLEPLAY_SERVICE_URL:-http://host.docker.internal:8008}
+ QSCORE_SERVICE_URL: ${QSCORE_SERVICE_URL:-http://host.docker.internal:8000}
USER_CONTAINER_HOST: ${USER_CONTAINER_HOST:-host.docker.internal}
USER_DATA_ROOT: /data/users
USER_PORT_RANGE_START: 20000
diff --git a/docs/architecture.html b/docs/architecture.html
index 069f739..9d4d857 100644
--- a/docs/architecture.html
+++ b/docs/architecture.html
@@ -366,7 +366,7 @@
diff --git a/package-lock.json b/package-lock.json
index ec8e214..65a3da3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,6 @@
"name": "growqr-backend",
"version": "0.1.0",
"dependencies": {
- "@anthropic-ai/sdk": "^0.96.0",
"@clerk/backend": "^1.21.0",
"@hono/node-server": "^1.13.7",
"dockerode": "^4.0.7",
@@ -38,36 +37,6 @@
"zod": "^3.25.0 || ^4.0.0"
}
},
- "node_modules/@anthropic-ai/sdk": {
- "version": "0.96.0",
- "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.96.0.tgz",
- "integrity": "sha512-KlCsODtTyb17bLUVCSDC2HtSvAbJf60sEiPEax9dInF+aDF92vS4TZJ5XD7YCQXNb1/5icYaw8Y7wMjPlIV9Zg==",
- "license": "MIT",
- "dependencies": {
- "json-schema-to-ts": "^3.1.1",
- "standardwebhooks": "^1.0.0"
- },
- "bin": {
- "anthropic-ai-sdk": "bin/cli"
- },
- "peerDependencies": {
- "zod": "^3.25.0 || ^4.0.0"
- },
- "peerDependenciesMeta": {
- "zod": {
- "optional": true
- }
- }
- },
- "node_modules/@babel/runtime": {
- "version": "7.29.2",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
- "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
"node_modules/@balena/dockerignore": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
@@ -244,7 +213,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -261,7 +229,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -278,7 +245,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -295,7 +261,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -312,7 +277,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -329,7 +293,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -346,7 +309,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -363,7 +325,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -380,7 +341,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -397,7 +357,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -414,7 +373,6 @@
"cpu": [
"loong64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -431,7 +389,6 @@
"cpu": [
"mips64el"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -448,7 +405,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -465,7 +421,6 @@
"cpu": [
"riscv64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -482,7 +437,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -499,7 +453,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -516,7 +469,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -533,7 +485,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -550,7 +501,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -567,7 +517,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -584,7 +533,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -601,7 +549,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -668,7 +615,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -685,7 +631,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -702,7 +647,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -719,7 +663,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -736,7 +679,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -753,7 +695,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -770,7 +711,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -787,7 +727,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -804,7 +743,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -821,7 +759,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -838,7 +775,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -855,7 +791,6 @@
"cpu": [
"loong64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -872,7 +807,6 @@
"cpu": [
"mips64el"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -889,7 +823,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -906,7 +839,6 @@
"cpu": [
"riscv64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -923,7 +855,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -940,7 +871,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -957,7 +887,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -974,7 +903,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -991,7 +919,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1008,7 +935,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1025,7 +951,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1042,7 +967,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1059,7 +983,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1076,7 +999,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1093,7 +1015,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1157,7 +1078,6 @@
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18.14.1"
},
@@ -2096,12 +2016,6 @@
"linux"
]
},
- "node_modules/@stablelib/base64": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
- "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
- "license": "MIT"
- },
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@@ -2305,7 +2219,6 @@
"integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
@@ -2960,7 +2873,6 @@
"resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz",
"integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"@balena/dockerignore": "^1.0.2",
"@grpc/grpc-js": "^1.11.1",
@@ -3014,7 +2926,6 @@
"integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@drizzle-team/brocli": "^0.10.2",
"@esbuild-kit/esm-loader": "^2.5.5",
@@ -3032,7 +2943,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3049,7 +2959,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3066,7 +2975,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3083,7 +2991,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3100,7 +3007,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3117,7 +3023,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3134,7 +3039,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3151,7 +3055,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3168,7 +3071,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3185,7 +3087,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3202,7 +3103,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3219,7 +3119,6 @@
"cpu": [
"loong64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3236,7 +3135,6 @@
"cpu": [
"mips64el"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3253,7 +3151,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3270,7 +3167,6 @@
"cpu": [
"riscv64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3287,7 +3183,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3304,7 +3199,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3321,7 +3215,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3338,7 +3231,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3355,7 +3247,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3372,7 +3263,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3389,7 +3279,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3406,7 +3295,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3423,7 +3311,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3440,7 +3327,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3457,7 +3343,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3811,12 +3696,6 @@
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT"
},
- "node_modules/fast-sha256": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
- "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
- "license": "Unlicense"
- },
"node_modules/fdb-tuple": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fdb-tuple/-/fdb-tuple-1.0.0.tgz",
@@ -3870,7 +3749,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -3937,7 +3815,6 @@
"resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz",
"integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=16"
},
@@ -4091,7 +3968,6 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz",
"integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -4314,19 +4190,6 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
- "node_modules/json-schema-to-ts": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
- "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.18.3",
- "ts-algebra": "^2.0.0"
- },
- "engines": {
- "node": ">=16"
- }
- },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -4888,7 +4751,6 @@
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
"license": "Unlicense",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -5380,7 +5242,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -5774,16 +5635,6 @@
"nan": "^2.23.0"
}
},
- "node_modules/standardwebhooks": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
- "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
- "license": "MIT",
- "dependencies": {
- "@stablelib/base64": "^1.0.0",
- "fast-sha256": "^1.3.0"
- }
- },
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@@ -5972,12 +5823,6 @@
"node": ">= 0.4"
}
},
- "node_modules/ts-algebra": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
- "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
- "license": "MIT"
- },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -6307,7 +6152,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index d58bfc6..a3bf045 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,6 @@
"compose:down": "docker compose down"
},
"dependencies": {
- "@anthropic-ai/sdk": "^0.96.0",
"@clerk/backend": "^1.21.0",
"@hono/node-server": "^1.13.7",
"dockerode": "^4.0.7",
diff --git a/src/actors/grow-agent.ts b/src/actors/grow-agent.ts
index 6ab564d..c28cd08 100644
--- a/src/actors/grow-agent.ts
+++ b/src/actors/grow-agent.ts
@@ -1,12 +1,13 @@
import { actor } from "rivetkit";
-import type Anthropic from "@anthropic-ai/sdk";
import { log } from "../log.js";
import { config } from "../config.js";
import {
- anthropic,
+ createChatCompletion,
GROW_AGENT_SYSTEM,
growAgentTools,
-} from "../lib/anthropic.js";
+ type LlmMessage,
+ type LlmToolCall,
+} from "../lib/llm.js";
import {
provisionUserStack,
getUserStack,
@@ -18,9 +19,10 @@ import { db } from "../db/client.js";
import { actors as actorsTable, events as eventsTable } from "../db/schema.js";
type ChatTurn = {
- role: "user" | "assistant";
- // Anthropic content blocks; "user" turns may also be plain strings.
- content: string | Anthropic.ContentBlockParam[];
+ role: "user" | "assistant" | "tool";
+ content: string;
+ toolCallId?: string;
+ toolCalls?: LlmToolCall[];
};
type GrowAgentState = {
@@ -73,7 +75,7 @@ export const growAgent = actor({
return stack;
},
- // Main chat entry point. Runs the full agentic loop with Claude.
+ // Main chat entry point. Runs the full agentic loop through the configured LLM.
receiveMessage: async (c, msg: { text: string }) => {
if (!c.state.userId) {
throw new Error("Grow Agent not initialized");
@@ -126,7 +128,8 @@ export const growAgent = actor({
},
});
-// The agentic loop. Keeps calling Claude with tools until stop_reason === "end_turn".
+// The agentic loop. Keeps calling the configured LLM with tools until the model
+// returns a normal assistant turn.
async function runAgentLoop(
c: {
state: GrowAgentState;
@@ -134,9 +137,9 @@ async function runAgentLoop(
},
userId: string,
): Promise
{
- if (!config.anthropicApiKey) {
+ if (!config.llmApiKey) {
const reply =
- "ANTHROPIC_API_KEY is not configured on the backend — set it to enable the Grow Agent.";
+ "LLM_API_KEY or OPENCODE_API_KEY is not configured on the backend - set it to enable the Grow Agent.";
c.state.history.push({ role: "assistant", content: reply });
c.broadcast("message", { role: "agent", text: reply });
return reply;
@@ -148,78 +151,80 @@ async function runAgentLoop(
let assistantTextOut = "";
for (let i = 0; i < MAX_ITERATIONS; i++) {
- const response = await anthropic.messages.create({
+ const response = await createChatCompletion({
model: config.growAgentModel,
- max_tokens: config.maxAgentTokens,
- system: [
- {
- type: "text",
- text: GROW_AGENT_SYSTEM,
- cache_control: { type: "ephemeral" },
- },
- ],
+ maxTokens: config.maxAgentTokens,
tools: growAgentTools,
- thinking: { type: "adaptive" },
messages: messagesForApi(c.state.history),
});
// Capture assistant text for streaming-style broadcast.
- for (const block of response.content) {
- if (block.type === "text" && block.text) {
- assistantTextOut += (assistantTextOut ? "\n\n" : "") + block.text;
- c.broadcast("message", { role: "agent", text: block.text });
- }
+ if (response.content) {
+ assistantTextOut += (assistantTextOut ? "\n\n" : "") + response.content;
+ c.broadcast("message", { role: "agent", text: response.content });
}
- // Persist the full assistant turn (so subsequent loops keep tool_use blocks).
+ // Persist the assistant turn, including tool calls for the next tool result turn.
c.state.history.push({
role: "assistant",
- content: response.content as Anthropic.ContentBlockParam[],
+ content: response.content,
+ toolCalls: response.toolCalls,
});
- if (response.stop_reason !== "tool_use") {
+ if (response.toolCalls.length === 0) {
break;
}
- const toolUses = response.content.filter(
- (b): b is Anthropic.ToolUseBlock => b.type === "tool_use",
- );
- if (toolUses.length === 0) break;
-
- const toolResults: Anthropic.ToolResultBlockParam[] = [];
- for (const call of toolUses) {
+ for (const call of response.toolCalls) {
try {
const result = await dispatchTool(c, userId, call);
- toolResults.push({
- type: "tool_result",
- tool_use_id: call.id,
+ c.state.history.push({
+ role: "tool",
+ toolCallId: call.id,
content: typeof result === "string" ? result : JSON.stringify(result),
});
} catch (err) {
log.error({ err, tool: call.name }, "tool dispatch failed");
- toolResults.push({
- type: "tool_result",
- tool_use_id: call.id,
+ c.state.history.push({
+ role: "tool",
+ toolCallId: call.id,
content: `Error: ${err instanceof Error ? err.message : String(err)}`,
- is_error: true,
});
}
}
-
- c.state.history.push({ role: "user", content: toolResults });
}
c.broadcast("agent-thinking", { state: "idle" });
return assistantTextOut || "(no response)";
}
-function messagesForApi(
- history: ChatTurn[],
-): Anthropic.MessageParam[] {
- return history.map((t) => ({
- role: t.role,
- content: t.content,
- })) as Anthropic.MessageParam[];
+function messagesForApi(history: ChatTurn[]): LlmMessage[] {
+ const messages: LlmMessage[] = [
+ { role: "system", content: GROW_AGENT_SYSTEM },
+ ];
+ for (const turn of history) {
+ if (turn.role === "tool") {
+ messages.push({
+ role: "tool",
+ content: turn.content,
+ tool_call_id: turn.toolCallId,
+ });
+ continue;
+ }
+ messages.push({
+ role: turn.role,
+ content: turn.content,
+ tool_calls: turn.toolCalls?.map((call) => ({
+ id: call.id,
+ type: "function",
+ function: {
+ name: call.name,
+ arguments: JSON.stringify(call.arguments),
+ },
+ })),
+ });
+ }
+ return messages;
}
async function dispatchTool(
@@ -228,9 +233,9 @@ async function dispatchTool(
state: GrowAgentState;
},
userId: string,
- call: Anthropic.ToolUseBlock,
+ call: LlmToolCall,
): Promise {
- const input = call.input as Record;
+ const input = call.arguments;
switch (call.name) {
case "spawn_sub_agent": {
const type = String(input.type ?? "generic");
diff --git a/src/actors/registry.ts b/src/actors/registry.ts
index cfa8b81..e377828 100644
--- a/src/actors/registry.ts
+++ b/src/actors/registry.ts
@@ -1,11 +1,13 @@
import { setup } from "rivetkit";
import { growAgent } from "./grow-agent.js";
import { subAgent } from "./sub-agent.js";
+import { workflowJob } from "./workflow-job.js";
export const registry = setup({
use: {
growAgent,
subAgent,
+ workflowJob,
},
});
diff --git a/src/actors/workflow-job.ts b/src/actors/workflow-job.ts
new file mode 100644
index 0000000..90c7dab
--- /dev/null
+++ b/src/actors/workflow-job.ts
@@ -0,0 +1,292 @@
+import { actor } from "rivetkit";
+import {
+ agentCatalog,
+ getAgentProfile,
+ jobApplicationAgentIds,
+ type AgentProfile,
+} from "../agents/catalog.js";
+import { runServiceAgentProbe, type ServiceAgentResult } from "../services/service-agents.js";
+
+type WorkflowStatus = "draft" | "running" | "paused" | "completed";
+type AgentStatus = "idle" | "running" | "blocked" | "done";
+
+type AgentScorecard = {
+ id: string;
+ question: string;
+ answer: string;
+ score: number;
+ notes?: string;
+ createdAt: string;
+};
+
+type WorkflowAgentState = {
+ id: string;
+ name: string;
+ role: string;
+ kind: AgentProfile["kind"];
+ service?: AgentProfile["service"];
+ status: AgentStatus;
+ summary: string;
+ lastResult?: ServiceAgentResult;
+ scorecards: AgentScorecard[];
+};
+
+type WorkflowEvent = {
+ id: string;
+ ts: string;
+ agentId: string;
+ agentName: string;
+ type: "workflow" | "agent" | "score";
+ message: string;
+ payload?: unknown;
+};
+
+type WorkflowJobState = {
+ workflowId: string;
+ userId: string;
+ type: "job-application";
+ status: WorkflowStatus;
+ goal: string;
+ agents: WorkflowAgentState[];
+ timeline: WorkflowEvent[];
+ createdAt: string;
+ updatedAt: string;
+};
+
+const now = () => new Date().toISOString();
+
+const initialState: WorkflowJobState = {
+ workflowId: "",
+ userId: "",
+ type: "job-application",
+ status: "draft",
+ goal: "",
+ agents: [],
+ timeline: [],
+ createdAt: "",
+ updatedAt: "",
+};
+
+function eventId() {
+ return `evt_${Date.now()}_${Math.random().toString(16).slice(2)}`;
+}
+
+function makeAgents(): WorkflowAgentState[] {
+ return jobApplicationAgentIds()
+ .map((id) => getAgentProfile(id))
+ .filter((agent): agent is AgentProfile => Boolean(agent))
+ .map((agent) => ({
+ id: agent.id,
+ name: agent.name,
+ role: agent.role,
+ kind: agent.kind,
+ service: agent.service,
+ status: "idle",
+ summary: agent.description,
+ scorecards: [],
+ }));
+}
+
+function appendEvent(
+ state: WorkflowJobState,
+ agent: Pick,
+ type: WorkflowEvent["type"],
+ message: string,
+ payload?: unknown,
+) {
+ const ev: WorkflowEvent = {
+ id: eventId(),
+ ts: now(),
+ agentId: agent.id,
+ agentName: agent.name,
+ type,
+ message,
+ payload,
+ };
+ state.timeline.unshift(ev);
+ state.timeline = state.timeline.slice(0, 100);
+ state.updatedAt = ev.ts;
+ return ev;
+}
+
+function localAgentResult(agent: WorkflowAgentState, goal: string): ServiceAgentResult {
+ const goalText = goal || "job application workflow";
+ switch (agent.id) {
+ case "grow":
+ return {
+ status: "local",
+ summary: `Grow Agent initialized the ${goalText} workflow and assigned specialist agents.`,
+ };
+ case "resume":
+ return {
+ status: "local",
+ summary: `Resume Agent prepared a resume-improvement pass for ${goalText}.`,
+ };
+ case "job-search":
+ return {
+ status: "local",
+ summary: `Job Search Agent created the opportunity discovery lane for ${goalText}.`,
+ };
+ case "job-apply":
+ return {
+ status: "local",
+ summary: `Job Apply Agent prepared the application tracking lane for ${goalText}.`,
+ };
+ default:
+ return {
+ status: "local",
+ summary: `${agent.name} completed a local workflow step.`,
+ };
+ }
+}
+
+export const workflowJob = actor({
+ options: {
+ actionTimeout: 600_000,
+ noSleep: true,
+ },
+ state: initialState,
+ actions: {
+ init: async (
+ c,
+ input: {
+ userId: string;
+ goal?: string;
+ },
+ ) => {
+ if (c.state.userId && c.state.userId !== input.userId) {
+ throw new Error("Workflow already belongs to another user");
+ }
+ if (!c.state.workflowId) {
+ const ts = now();
+ c.state.workflowId = `job-application:${input.userId}`;
+ c.state.userId = input.userId;
+ c.state.type = "job-application";
+ c.state.goal = input.goal ?? "Find and apply to high-fit jobs";
+ c.state.status = "draft";
+ c.state.agents = makeAgents();
+ c.state.createdAt = ts;
+ c.state.updatedAt = ts;
+ appendEvent(
+ c.state,
+ { id: "grow", name: "Grow Agent" },
+ "workflow",
+ "Job application workflow created.",
+ { catalog: agentCatalog },
+ );
+ } else if (input.goal) {
+ c.state.goal = input.goal;
+ c.state.updatedAt = now();
+ }
+ c.broadcast("workflow.updated", c.state);
+ return c.state;
+ },
+
+ start: async (c) => {
+ c.state.status = "running";
+ appendEvent(
+ c.state,
+ { id: "grow", name: "Grow Agent" },
+ "workflow",
+ "Workflow started.",
+ );
+ c.broadcast("workflow.updated", c.state);
+ return c.state;
+ },
+
+ pause: async (c) => {
+ c.state.status = "paused";
+ appendEvent(
+ c.state,
+ { id: "grow", name: "Grow Agent" },
+ "workflow",
+ "Workflow paused.",
+ );
+ c.broadcast("workflow.updated", c.state);
+ return c.state;
+ },
+
+ resume: async (c) => {
+ c.state.status = "running";
+ appendEvent(
+ c.state,
+ { id: "grow", name: "Grow Agent" },
+ "workflow",
+ "Workflow resumed.",
+ );
+ c.broadcast("workflow.updated", c.state);
+ return c.state;
+ },
+
+ runAgent: async (c, input: { agentId: string }) => {
+ const agent = c.state.agents.find((item) => item.id === input.agentId);
+ if (!agent) throw new Error(`Unknown workflow agent: ${input.agentId}`);
+
+ agent.status = "running";
+ appendEvent(c.state, agent, "agent", `${agent.name} started.`);
+ c.broadcast("workflow.updated", c.state);
+
+ const profile = getAgentProfile(agent.id);
+ if (profile?.service != null) {
+ const userId = c.state.userId;
+ const goal = c.state.goal;
+ c.waitUntil(
+ (async () => {
+ const result = await runServiceAgentProbe(profile, {
+ userId,
+ goal,
+ });
+ agent.lastResult = result;
+ agent.status = result.status === "unavailable" ? "blocked" : "done";
+ appendEvent(c.state, agent, "agent", result.summary, result.detail);
+ c.broadcast("workflow.updated", c.state);
+ await c.saveState({ immediate: true });
+ })(),
+ );
+ return c.state;
+ }
+
+ const result = localAgentResult(agent, c.state.goal);
+
+ agent.lastResult = result;
+ agent.status = result.status === "unavailable" ? "blocked" : "done";
+ appendEvent(c.state, agent, "agent", result.summary, result.detail);
+ c.broadcast("workflow.updated", c.state);
+ return c.state;
+ },
+
+ recordQaScore: async (
+ c,
+ input: {
+ agentId: string;
+ question: string;
+ answer: string;
+ score: number;
+ notes?: string;
+ },
+ ) => {
+ const agent = c.state.agents.find((item) => item.id === input.agentId);
+ if (!agent) throw new Error(`Unknown workflow agent: ${input.agentId}`);
+ const card: AgentScorecard = {
+ id: `score_${Date.now()}`,
+ question: input.question,
+ answer: input.answer,
+ score: Math.max(0, Math.min(100, Number(input.score))),
+ notes: input.notes,
+ createdAt: now(),
+ };
+ agent.scorecards.unshift(card);
+ appendEvent(
+ c.state,
+ agent,
+ "score",
+ `${agent.name} recorded Q&A score ${card.score}.`,
+ card,
+ );
+ c.broadcast("workflow.updated", c.state);
+ return c.state;
+ },
+
+ getStatus: async (c) => c.state,
+ },
+});
diff --git a/src/agents/catalog.ts b/src/agents/catalog.ts
new file mode 100644
index 0000000..350df5b
--- /dev/null
+++ b/src/agents/catalog.ts
@@ -0,0 +1,94 @@
+export type AgentKind =
+ | "master"
+ | "workflow"
+ | "microservice"
+ | "score";
+
+export type AgentProfile = {
+ id: string;
+ name: string;
+ role: string;
+ kind: AgentKind;
+ description: string;
+ service?: "interview-service" | "roleplay-service" | "qscore-service";
+};
+
+export const agentCatalog = [
+ {
+ id: "grow",
+ name: "Grow Agent",
+ role: "Master Orchestrator",
+ kind: "master",
+ description:
+ "Owns user context, routes work to sub-agents, commits durable memory, and tracks workflow progress.",
+ },
+ {
+ id: "resume",
+ name: "Resume Agent",
+ role: "Resume Builder",
+ kind: "workflow",
+ description:
+ "Turns profile context, Q-Score gaps, and target roles into resume edits and application collateral.",
+ },
+ {
+ id: "job-search",
+ name: "Job Search Agent",
+ role: "Opportunity Scout",
+ kind: "workflow",
+ description:
+ "Finds relevant jobs, ranks opportunities, and prepares a shortlist for the application workflow.",
+ },
+ {
+ id: "job-apply",
+ name: "Job Apply Agent",
+ role: "Application Operator",
+ kind: "workflow",
+ description:
+ "Prepares tailored applications, tracks submissions, and records follow-up tasks.",
+ },
+ {
+ id: "sara",
+ name: "Sara",
+ role: "Interview Agent",
+ kind: "microservice",
+ service: "interview-service",
+ description:
+ "Runs interview practice through the interview-service microservice and owns interview Q&A feedback.",
+ },
+ {
+ id: "emily",
+ name: "Emily",
+ role: "Roleplay Agent",
+ kind: "microservice",
+ service: "roleplay-service",
+ description:
+ "Runs roleplay practice through the roleplay-service microservice and owns scenario feedback.",
+ },
+ {
+ id: "qscore",
+ name: "Quinn",
+ role: "Q-Score Agent",
+ kind: "score",
+ service: "qscore-service",
+ description:
+ "Computes and explains Q-Score changes, then displays Q&A and scores under the owning agent.",
+ },
+] as const satisfies AgentProfile[];
+
+export type AgentId = (typeof agentCatalog)[number]["id"];
+
+export function getAgentProfile(id: string): AgentProfile | undefined {
+ return agentCatalog.find((agent) => agent.id === id);
+}
+
+export function jobApplicationAgentIds(): AgentId[] {
+ return [
+ "grow",
+ "resume",
+ "job-search",
+ "job-apply",
+ "sara",
+ "emily",
+ "qscore",
+ ];
+}
diff --git a/src/auth/clerk.ts b/src/auth/clerk.ts
index eb232d0..3f4d76a 100644
--- a/src/auth/clerk.ts
+++ b/src/auth/clerk.ts
@@ -34,7 +34,19 @@ export const requireUser = createMiddleware(async (c, next) => {
c.req.header("x-growqr-user")
) {
const userId = c.req.header("x-growqr-user")!;
- const row = await db.query.users.findFirst({ where: eq(users.id, userId) });
+ let row = await db.query.users.findFirst({ where: eq(users.id, userId) });
+ if (!row) {
+ const inserted = await db
+ .insert(users)
+ .values({
+ id: userId,
+ email: `${userId}@service.local`,
+ displayName: userId,
+ })
+ .onConflictDoNothing()
+ .returning();
+ row = inserted[0];
+ }
if (!row) {
throw new HTTPException(401, { message: "service token references unknown user" });
}
diff --git a/src/config.ts b/src/config.ts
index b135d9d..a1fb960 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -23,21 +23,46 @@ export const config = {
clerkPublishableKey: process.env.CLERK_PUBLISHABLE_KEY ?? "",
// Optional: lock service-to-service calls (actor → backend).
serviceToken: process.env.SERVICE_TOKEN ?? "",
+ a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key",
- // Anthropic for Grow Agent + sub-agent LLM calls.
- anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? "",
+ // LLM gateway for Grow Agent + sub-agent planning calls.
+ llmProvider: process.env.LLM_PROVIDER ?? "opencode",
+ llmApiKey:
+ process.env.LLM_API_KEY ??
+ process.env.OPENCODE_API_KEY ??
+ "",
+ llmBaseUrl:
+ process.env.LLM_BASE_URL ??
+ process.env.OPENCODE_BASE_URL ??
+ "https://opencode.ai/zen/v1",
+ opencodeApiKey: process.env.OPENCODE_API_KEY ?? "",
growAgentModel:
- process.env.GROW_AGENT_MODEL ?? "claude-opus-4-7",
+ process.env.GROW_AGENT_MODEL ??
+ process.env.LLM_MODEL ??
+ "kimi-k2.6",
subAgentModel:
- process.env.SUB_AGENT_MODEL ?? "claude-sonnet-4-6",
+ process.env.SUB_AGENT_MODEL ??
+ process.env.LLM_MODEL ??
+ "kimi-k2.6",
// Rivet Kit engine endpoint (self-hosted in docker-compose).
rivetEndpoint: process.env.RIVET_ENDPOINT ?? "http://localhost:6420",
+ rivetClientEndpoint:
+ process.env.RIVET_CLIENT_ENDPOINT ??
+ `http://127.0.0.1:${Number(process.env.PORT ?? 4000)}/api/rivet`,
+
+ // Product microservices exposed as sub-agent surfaces.
+ interviewServiceUrl:
+ process.env.INTERVIEW_SERVICE_URL ?? "http://localhost:8007",
+ roleplayServiceUrl:
+ process.env.ROLEPLAY_SERVICE_URL ?? "http://localhost:8008",
+ qscoreServiceUrl:
+ process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
// Per-user container images.
giteaImage: process.env.GITEA_IMAGE ?? "gitea/gitea:1.22",
opencodeImage:
- process.env.OPENCODE_IMAGE ?? "ghcr.io/sst/opencode:latest",
+ process.env.OPENCODE_IMAGE ?? "ghcr.io/anomalyco/opencode:latest",
// Host that user containers expose ports on (the host running Docker).
userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1",
@@ -49,7 +74,7 @@ export const config = {
frontendOrigin:
process.env.FRONTEND_ORIGIN ?? "http://localhost:3000",
- // Used by Anthropic SDK extended thinking / streaming budgets.
+ // Used by LLM requests.
maxAgentTokens: Number(process.env.MAX_AGENT_TOKENS ?? 4096),
required, // exported so other modules can fail fast on boot
diff --git a/src/docker/manager.ts b/src/docker/manager.ts
index 6e1be5d..b9065c0 100644
--- a/src/docker/manager.ts
+++ b/src/docker/manager.ts
@@ -133,42 +133,95 @@ async function startGiteaContainer(opts: {
return { id: container.id, name };
}
+function shellQuote(value: string): string {
+ return `'${value.replace(/'/g, "'\\''")}'`;
+}
+
+async function execGiteaCli(containerId: string, args: string[]): Promise {
+ const container = docker.getContainer(containerId);
+ const command = [
+ "gitea",
+ "--work-path",
+ "/data/gitea",
+ "--config",
+ "/data/gitea/conf/app.ini",
+ ...args,
+ ]
+ .map(shellQuote)
+ .join(" ");
+ const exec = await container.exec({
+ Cmd: ["su", "git", "-c", command],
+ AttachStdout: true,
+ AttachStderr: true,
+ WorkingDir: "/data/gitea",
+ });
+ const stream = await exec.start({ Detach: false, Tty: false });
+ const chunks: Buffer[] = [];
+ stream.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk)));
+ await new Promise((resolve) => {
+ stream.on("end", () => resolve());
+ stream.on("close", () => resolve());
+ });
+ const output = Buffer.concat(chunks).toString("utf8");
+ const info = await exec.inspect();
+ if (info.ExitCode && info.ExitCode !== 0) {
+ throw new Error(`gitea cli failed (${info.ExitCode}): ${output}`);
+ }
+ return output;
+}
+
// Runs `gitea admin user create --admin ...` inside the container.
-// Idempotent: returns existing creds if the user already exists.
+// Idempotent: the CLI returns non-zero if the user already exists, which is fine.
async function ensureGiteaAdmin(opts: {
containerId: string;
username: string;
password: string;
email: string;
}): Promise {
- const container = docker.getContainer(opts.containerId);
- const exec = await container.exec({
- Cmd: [
- "su",
- "git",
- "-c",
- `gitea admin user create --admin --username ${opts.username} --password '${opts.password.replace(/'/g, "'\\''")}' --email ${opts.email} --must-change-password=false`,
- ],
- AttachStdout: true,
- AttachStderr: true,
- WorkingDir: "/var/lib/gitea",
- });
- const stream = await exec.start({ Detach: false, Tty: false });
- await new Promise((resolve) => {
- stream.on("end", () => resolve());
- stream.on("close", () => resolve());
- stream.resume();
- });
- const info = await exec.inspect();
- if (info.ExitCode && info.ExitCode !== 0) {
- // Most common non-zero: "user already exists" — that's fine.
+ try {
+ await execGiteaCli(opts.containerId, [
+ "admin",
+ "user",
+ "create",
+ "--admin",
+ "--username",
+ opts.username,
+ "--password",
+ opts.password,
+ "--email",
+ opts.email,
+ "--must-change-password=false",
+ ]);
+ } catch (err) {
log.debug(
- { exitCode: info.ExitCode },
+ { err },
"gitea admin user create returned non-zero (likely already exists)",
);
}
}
+async function generateGiteaToken(opts: {
+ containerId: string;
+ username: string;
+ scopes: string[];
+}): Promise {
+ const output = await execGiteaCli(opts.containerId, [
+ "admin",
+ "user",
+ "generate-access-token",
+ "--username",
+ opts.username,
+ "--token-name",
+ `growqr-backend-${Date.now()}`,
+ "--scopes",
+ opts.scopes.join(","),
+ "--raw",
+ ]);
+ const token = output.match(/[a-f0-9]{40}/i)?.[0];
+ if (!token) throw new Error("gitea token generation returned an empty token");
+ return token;
+}
+
async function startOpencodeContainer(opts: {
userId: string;
httpPort: number;
@@ -298,15 +351,11 @@ export async function provisionUserStack(userId: string): Promise {
email: adminEmail,
});
- // Mint a token using basic auth.
- const basicClient = new GiteaClient(giteaBase, {
- kind: "basic",
+ // Mint a token via Gitea's CLI so retries do not depend on a transient
+ // bootstrap password from a previous provisioning attempt.
+ const token = await generateGiteaToken({
+ containerId: gitea.id,
username: adminUsername,
- password: adminPassword,
- });
- const token = await basicClient.ensureAccessToken({
- username: adminUsername,
- name: "growqr-backend",
scopes: ["write:repository", "write:user", "write:issue"],
});
diff --git a/src/index.ts b/src/index.ts
index 009c4e3..d5ac02a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -9,6 +9,8 @@ import { actorRoutes } from "./routes/actors.js";
import { opencodeRoutes } from "./routes/opencode.js";
import { gitRoutes } from "./routes/git.js";
import { userRoutes } from "./routes/users.js";
+import { agentRoutes } from "./routes/agents.js";
+import { workflowRoutes } from "./routes/workflows.js";
import { db } from "./db/client.js";
import { hydratePortAllocator, reconcileOnBoot } from "./docker/manager.js";
@@ -58,10 +60,17 @@ async function main() {
// PRD HTTP control plane (auth-gated).
app.route("/users", userRoutes());
+ app.route("/agents", agentRoutes());
+ app.route("/workflows", workflowRoutes());
app.route("/actors", actorRoutes());
app.route("/opencode", opencodeRoutes());
app.route("/git", gitRoutes());
+ if (process.env.RIVET_RUN_ENGINE === "1") {
+ delete process.env.RIVET_ENDPOINT;
+ }
+ registry.startRunner();
+
serve({ fetch: app.fetch, port: config.port }, (info) => {
log.info(
{
diff --git a/src/lib/anthropic.ts b/src/lib/anthropic.ts
deleted file mode 100644
index 77903d5..0000000
--- a/src/lib/anthropic.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import Anthropic from "@anthropic-ai/sdk";
-import { config } from "../config.js";
-
-export const anthropic = new Anthropic({
- apiKey: config.anthropicApiKey,
-});
-
-export const GROW_AGENT_SYSTEM = `You are a Grow Agent — a user's master AI orchestrator on the GrowQR platform.
-
-You own this user's long-running context, memory, and workspace. You coordinate specialized sub-agents (coding, repo, quest, product-flow, etc.), keep durable state in the user's Gitea memory repository, and execute workflows via the user's OpenCode sandbox.
-
-Operating principles:
-- Be concise and direct. The user sees your messages in a Slack-like chat.
-- Maintain durable memory: commit important decisions, goals, and progress to the user's memory repo using \`commit_memory\`. Read existing context with \`read_memory\` before making suggestions that depend on history.
-- For anything that requires code, shell, file edits, or generated artifacts, spawn a sub-agent via \`spawn_sub_agent\`. The sub-agent runs through the user's OpenCode container.
-- Track active goals and quests. Surface progress proactively when the user returns.
-- Prefer one small commit per meaningful state change over batching.
-- Never invent tool names. Only use the tools provided.
-`;
-
-export type GrowAgentTool =
- | "spawn_sub_agent"
- | "commit_memory"
- | "read_memory"
- | "list_memory";
-
-export const growAgentTools: Anthropic.Tool[] = [
- {
- name: "spawn_sub_agent",
- description:
- "Spawn a specialized sub-agent to run a bounded task through the user's OpenCode container. Use for anything that requires running code, editing files, or producing artifacts.",
- input_schema: {
- type: "object",
- properties: {
- type: {
- type: "string",
- description:
- "Sub-agent type: 'coding', 'repo', 'migration', 'quest', 'product', 'backend', 'frontend', or another short identifier.",
- },
- prompt: {
- type: "string",
- description:
- "The full task prompt for the sub-agent. Include all context it needs — sub-agents do not see this conversation.",
- },
- channelId: {
- type: "string",
- description:
- "Optional channel/thread id the sub-agent should report into. Generated if omitted.",
- },
- },
- required: ["type", "prompt"],
- },
- },
- {
- name: "commit_memory",
- description:
- "Write or update a file in the user's Gitea memory repository. Use for goals, decisions, progress notes, plans, and durable summaries.",
- input_schema: {
- type: "object",
- properties: {
- path: {
- type: "string",
- description:
- "Repo-relative path, e.g. 'goals/active.md' or 'decisions/2026-05-19-architecture.md'.",
- },
- content: {
- type: "string",
- description: "Full UTF-8 file content to write.",
- },
- message: {
- type: "string",
- description: "Commit message describing the change.",
- },
- },
- required: ["path", "content", "message"],
- },
- },
- {
- name: "read_memory",
- description: "Read a single file from the user's memory repo. Returns null if missing.",
- input_schema: {
- type: "object",
- properties: {
- path: { type: "string" },
- },
- required: ["path"],
- },
- },
- {
- name: "list_memory",
- description:
- "List files at a path prefix in the user's memory repo. Use to discover what context already exists.",
- input_schema: {
- type: "object",
- properties: {
- pathPrefix: {
- type: "string",
- description: "Repo-relative directory, e.g. 'goals' or '' for root.",
- },
- },
- required: ["pathPrefix"],
- },
- },
-];
diff --git a/src/lib/llm.ts b/src/lib/llm.ts
new file mode 100644
index 0000000..2d6c917
--- /dev/null
+++ b/src/lib/llm.ts
@@ -0,0 +1,229 @@
+import { config } from "../config.js";
+
+export const GROW_AGENT_SYSTEM = `You are a Grow Agent - a user's master AI orchestrator on the GrowQR platform.
+
+You own this user's long-running context, memory, and workspace. You coordinate specialized sub-agents (coding, repo, quest, product-flow, etc.), keep durable state in the user's Gitea memory repository, and execute workflows via the user's OpenCode sandbox.
+
+Operating principles:
+- Be concise and direct. The user sees your messages in a Slack-like chat.
+- Maintain durable memory: commit important decisions, goals, and progress to the user's memory repo using \`commit_memory\`. Read existing context with \`read_memory\` before making suggestions that depend on history.
+- For anything that requires code, shell, file edits, or generated artifacts, spawn a sub-agent via \`spawn_sub_agent\`. The sub-agent runs through the user's OpenCode container.
+- Track active goals and quests. Surface progress proactively when the user returns.
+- Prefer one small commit per meaningful state change over batching.
+- Never invent tool names. Only use the tools provided.
+`;
+
+export type GrowAgentTool =
+ | "spawn_sub_agent"
+ | "commit_memory"
+ | "read_memory"
+ | "list_memory";
+
+export type LlmTool = {
+ type: "function";
+ function: {
+ name: GrowAgentTool;
+ description: string;
+ parameters: Record;
+ };
+};
+
+export type LlmToolCall = {
+ id: string;
+ name: string;
+ arguments: Record;
+};
+
+export type LlmMessage = {
+ role: "system" | "user" | "assistant" | "tool";
+ content: string | null;
+ tool_call_id?: string;
+ tool_calls?: Array<{
+ id: string;
+ type: "function";
+ function: {
+ name: string;
+ arguments: string;
+ };
+ }>;
+};
+
+export const growAgentTools: LlmTool[] = [
+ {
+ type: "function",
+ function: {
+ name: "spawn_sub_agent",
+ description:
+ "Spawn a specialized sub-agent to run a bounded task through the user's OpenCode container. Use for anything that requires running code, editing files, or producing artifacts.",
+ parameters: {
+ type: "object",
+ properties: {
+ type: {
+ type: "string",
+ description:
+ "Sub-agent type: 'coding', 'repo', 'migration', 'quest', 'product', 'backend', 'frontend', or another short identifier.",
+ },
+ prompt: {
+ type: "string",
+ description:
+ "The full task prompt for the sub-agent. Include all context it needs - sub-agents do not see this conversation.",
+ },
+ channelId: {
+ type: "string",
+ description:
+ "Optional channel/thread id the sub-agent should report into. Generated if omitted.",
+ },
+ },
+ required: ["type", "prompt"],
+ },
+ },
+ },
+ {
+ type: "function",
+ function: {
+ name: "commit_memory",
+ description:
+ "Write or update a file in the user's Gitea memory repository. Use for goals, decisions, progress notes, plans, and durable summaries.",
+ parameters: {
+ type: "object",
+ properties: {
+ path: {
+ type: "string",
+ description:
+ "Repo-relative path, e.g. 'goals/active.md' or 'decisions/2026-05-19-architecture.md'.",
+ },
+ content: {
+ type: "string",
+ description: "Full UTF-8 file content to write.",
+ },
+ message: {
+ type: "string",
+ description: "Commit message describing the change.",
+ },
+ },
+ required: ["path", "content", "message"],
+ },
+ },
+ },
+ {
+ type: "function",
+ function: {
+ name: "read_memory",
+ description: "Read a single file from the user's memory repo. Returns null if missing.",
+ parameters: {
+ type: "object",
+ properties: {
+ path: { type: "string" },
+ },
+ required: ["path"],
+ },
+ },
+ },
+ {
+ type: "function",
+ function: {
+ name: "list_memory",
+ description:
+ "List files at a path prefix in the user's memory repo. Use to discover what context already exists.",
+ parameters: {
+ type: "object",
+ properties: {
+ pathPrefix: {
+ type: "string",
+ description: "Repo-relative directory, e.g. 'goals' or '' for root.",
+ },
+ },
+ required: ["pathPrefix"],
+ },
+ },
+ },
+];
+
+type ChatCompletionsResponse = {
+ choices?: Array<{
+ finish_reason?: string;
+ message?: {
+ content?: string | null;
+ tool_calls?: Array<{
+ id: string;
+ type: "function";
+ function: {
+ name: string;
+ arguments?: string;
+ };
+ }>;
+ };
+ }>;
+ error?: {
+ message?: string;
+ };
+};
+
+function normalizeModel(model: string): string {
+ if (config.llmProvider === "opencode" && model.startsWith("opencode/")) {
+ return model.slice("opencode/".length);
+ }
+ return model;
+}
+
+function endpoint(path: string): string {
+ return `${config.llmBaseUrl.replace(/\/$/, "")}${path}`;
+}
+
+function parseToolArguments(raw: string | undefined): Record {
+ if (!raw) return {};
+ try {
+ const parsed = JSON.parse(raw);
+ return parsed && typeof parsed === "object"
+ ? (parsed as Record)
+ : {};
+ } catch {
+ return {};
+ }
+}
+
+export async function createChatCompletion(opts: {
+ model: string;
+ messages: LlmMessage[];
+ tools: LlmTool[];
+ maxTokens: number;
+}): Promise<{
+ content: string;
+ toolCalls: LlmToolCall[];
+ finishReason?: string;
+}> {
+ const res = await fetch(endpoint("/chat/completions"), {
+ method: "POST",
+ headers: {
+ authorization: `Bearer ${config.llmApiKey}`,
+ "content-type": "application/json",
+ accept: "application/json",
+ },
+ body: JSON.stringify({
+ model: normalizeModel(opts.model),
+ messages: opts.messages,
+ tools: opts.tools,
+ tool_choice: "auto",
+ max_tokens: opts.maxTokens,
+ }),
+ });
+
+ const json = (await res.json().catch(() => ({}))) as ChatCompletionsResponse;
+ if (!res.ok) {
+ const detail = json.error?.message ?? JSON.stringify(json);
+ throw new Error(`LLM request failed: ${res.status} ${res.statusText}: ${detail}`);
+ }
+
+ const choice = json.choices?.[0];
+ const message = choice?.message;
+ return {
+ content: message?.content ?? "",
+ finishReason: choice?.finish_reason,
+ toolCalls:
+ message?.tool_calls?.map((call) => ({
+ id: call.id,
+ name: call.function.name,
+ arguments: parseToolArguments(call.function.arguments),
+ })) ?? [],
+ };
+}
diff --git a/src/routes/agents.ts b/src/routes/agents.ts
new file mode 100644
index 0000000..6c115aa
--- /dev/null
+++ b/src/routes/agents.ts
@@ -0,0 +1,12 @@
+import { Hono } from "hono";
+import { agentCatalog } from "../agents/catalog.js";
+import { requireUser, type AuthContext } from "../auth/clerk.js";
+
+export function agentRoutes() {
+ const app = new Hono();
+ app.use("*", requireUser);
+
+ app.get("/catalog", (c) => c.json({ agents: agentCatalog }));
+
+ return app;
+}
diff --git a/src/routes/workflows.ts b/src/routes/workflows.ts
new file mode 100644
index 0000000..8174699
--- /dev/null
+++ b/src/routes/workflows.ts
@@ -0,0 +1,74 @@
+import { Hono } from "hono";
+import { z } from "zod";
+import { createClient } from "rivetkit/client";
+import { config } from "../config.js";
+import { requireUser, type AuthContext } from "../auth/clerk.js";
+import type { Registry } from "../actors/registry.js";
+
+const client = createClient(config.rivetEndpoint);
+
+function jobWorkflowFor(userId: string) {
+ return client.workflowJob.getOrCreate([userId, "job-application"]);
+}
+
+export function workflowRoutes() {
+ const app = new Hono();
+ app.use("*", requireUser);
+
+ app.post("/job-application", async (c) => {
+ const userId = c.get("userId");
+ const body = z
+ .object({ goal: z.string().min(1).optional() })
+ .parse(await c.req.json().catch(() => ({})));
+ const handle = jobWorkflowFor(userId);
+ const state = await handle.init({ userId, goal: body.goal });
+ const started = await handle.start();
+ return c.json({ workflow: started ?? state });
+ });
+
+ app.get("/job-application", async (c) => {
+ const userId = c.get("userId");
+ const handle = jobWorkflowFor(userId);
+ const state = await handle.init({ userId });
+ return c.json({ workflow: state });
+ });
+
+ app.post("/job-application/pause", async (c) => {
+ const userId = c.get("userId");
+ const workflow = await jobWorkflowFor(userId).pause();
+ return c.json({ workflow });
+ });
+
+ app.post("/job-application/resume", async (c) => {
+ const userId = c.get("userId");
+ const workflow = await jobWorkflowFor(userId).resume();
+ return c.json({ workflow });
+ });
+
+ app.post("/job-application/agents/:agentId/run", async (c) => {
+ const userId = c.get("userId");
+ const agentId = c.req.param("agentId");
+ const workflow = await jobWorkflowFor(userId).runAgent({ agentId });
+ return c.json({ workflow });
+ });
+
+ app.post("/job-application/agents/:agentId/score", async (c) => {
+ const userId = c.get("userId");
+ const agentId = c.req.param("agentId");
+ const body = z
+ .object({
+ question: z.string().min(1),
+ answer: z.string().min(1),
+ score: z.number().min(0).max(100),
+ notes: z.string().optional(),
+ })
+ .parse(await c.req.json());
+ const workflow = await jobWorkflowFor(userId).recordQaScore({
+ agentId,
+ ...body,
+ });
+ return c.json({ workflow });
+ });
+
+ return app;
+}
diff --git a/src/services/service-agents.ts b/src/services/service-agents.ts
new file mode 100644
index 0000000..8c92d9e
--- /dev/null
+++ b/src/services/service-agents.ts
@@ -0,0 +1,241 @@
+import { config } from "../config.js";
+import type { AgentProfile } from "../agents/catalog.js";
+import { createHash } from "node:crypto";
+
+export type ServiceAgentResult = {
+ status: "ok" | "unavailable" | "local";
+ summary: string;
+ detail?: unknown;
+};
+
+export type ServiceAgentContext = {
+ userId: string;
+ orgId?: string;
+ goal: string;
+};
+
+function stableUuid(input: string): string {
+ const hex = createHash("sha256").update(input).digest("hex").slice(0, 32);
+ return [
+ hex.slice(0, 8),
+ hex.slice(8, 12),
+ `4${hex.slice(13, 16)}`,
+ `8${hex.slice(17, 20)}`,
+ hex.slice(20, 32),
+ ].join("-");
+}
+
+async function serviceJson(
+ baseUrl: string,
+ path: string,
+ init?: RequestInit,
+): Promise {
+ const res = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, {
+ ...init,
+ headers: {
+ "content-type": "application/json",
+ ...(config.a2aAllowedKey
+ ? { authorization: `Bearer ${config.a2aAllowedKey}` }
+ : {}),
+ ...(init?.headers ?? {}),
+ },
+ });
+ const body = await res.text();
+ if (!res.ok) {
+ throw new Error(`${path} returned HTTP ${res.status}: ${body}`);
+ }
+ return (body ? JSON.parse(body) : {}) as T;
+}
+
+async function healthCheck(baseUrl: string, label: string): Promise {
+ try {
+ const detail = await serviceJson(baseUrl, "/health", { method: "GET" });
+ return {
+ status: "ok",
+ summary: `${label} is reachable and ready for workflow handoff.`,
+ detail,
+ };
+ } catch (err) {
+ return {
+ status: "unavailable",
+ summary: `${label} is not reachable yet: ${err instanceof Error ? err.message : String(err)}`,
+ };
+ }
+}
+
+async function runSaraInterview(ctx: ServiceAgentContext): Promise {
+ const payload = {
+ user_id: ctx.userId,
+ org_id: ctx.orgId ?? "growqr",
+ persona_id: "emma",
+ interview_type: "behavioral",
+ duration_minutes: 15,
+ context: {
+ target_role: ctx.goal,
+ company_name: "Target company",
+ difficulty: "medium",
+ source: "growqr-workflow",
+ },
+ };
+ const detail = await serviceJson>(
+ config.interviewServiceUrl,
+ "/api/v1/configure",
+ {
+ method: "POST",
+ body: JSON.stringify(payload),
+ },
+ );
+ return {
+ status: "ok",
+ summary: `Sara created interview session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
+ detail,
+ };
+}
+
+async function runEmilyRoleplay(ctx: ServiceAgentContext): Promise {
+ const payload = {
+ user_id: ctx.userId,
+ org_id: ctx.orgId ?? "growqr",
+ persona_id: "emma",
+ duration_minutes: 15,
+ roleplay_type: "custom",
+ brief: `Practice a realistic job-application conversation for: ${ctx.goal}. Include objection handling, concise self-pitching, and a closing next step.`,
+ metadata: {
+ target_role: ctx.goal,
+ difficulty: "medium",
+ source: "growqr-workflow",
+ },
+ qscore: {
+ q_score: 78,
+ profession: "student",
+ formula_version: "workflow-demo",
+ quotients: {
+ CQm: { score: 72, active: true },
+ XQ: { score: 80, active: true },
+ VQ: { score: 76, active: true },
+ },
+ },
+ };
+ const detail = await serviceJson>(
+ config.roleplayServiceUrl,
+ "/api/v1/roleplays/configure",
+ {
+ method: "POST",
+ body: JSON.stringify(payload),
+ },
+ );
+ return {
+ status: "ok",
+ summary: `Emily created roleplay session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
+ detail,
+ };
+}
+
+async function runQuinnQScore(ctx: ServiceAgentContext): Promise {
+ const orgId = ctx.orgId ?? "growqr";
+ const qscoreUserId = stableUuid(ctx.userId);
+ const signals = [
+ {
+ signal_id: "resume.uploaded",
+ present: true,
+ score: 82,
+ raw: { source: "resume-agent", workflow_goal: ctx.goal },
+ },
+ {
+ signal_id: "resume.ats_compatibility",
+ present: true,
+ score: 76,
+ raw: { source: "resume-agent", workflow_goal: ctx.goal },
+ },
+ {
+ signal_id: "engagement.features_used",
+ present: true,
+ score: 88,
+ raw: { source: "grow-agent", workflow_goal: ctx.goal },
+ },
+ {
+ signal_id: "goals.goal_clarity",
+ present: true,
+ score: 90,
+ raw: { source: "grow-agent", workflow_goal: ctx.goal },
+ },
+ ];
+
+ const ingest = await serviceJson>(
+ config.qscoreServiceUrl,
+ "/v1/signals/ingest",
+ {
+ method: "POST",
+ body: JSON.stringify({
+ org_id: orgId,
+ user_id: qscoreUserId,
+ profession: "student",
+ source: "growqr-workflow",
+ signals,
+ }),
+ },
+ );
+
+ let compute: Record | undefined;
+ try {
+ compute = await serviceJson>(
+ config.qscoreServiceUrl,
+ "/v1/qscore/compute",
+ {
+ method: "POST",
+ body: JSON.stringify({
+ org_id: orgId,
+ user_id: qscoreUserId,
+ }),
+ },
+ );
+ } catch (err) {
+ return {
+ status: "unavailable",
+ summary:
+ "Quinn ingested Q-Score signals, but computation is waiting for the QScore worker or formula store.",
+ detail: {
+ ingest,
+ compute_error: err instanceof Error ? err.message : String(err),
+ },
+ };
+ }
+
+ return {
+ status: "ok",
+ summary: `Quinn computed Q-Score ${compute.q_score ?? "(unknown)"} for ${ctx.goal}.`,
+ detail: { ingest, compute, qscore_user_id: qscoreUserId },
+ };
+}
+
+export async function runServiceAgentProbe(
+ agent: AgentProfile,
+ ctx?: ServiceAgentContext,
+): Promise {
+ try {
+ switch (agent.service) {
+ case "interview-service":
+ return ctx
+ ? await runSaraInterview(ctx)
+ : healthCheck(config.interviewServiceUrl, "Sara / interview-service");
+ case "roleplay-service":
+ return ctx
+ ? await runEmilyRoleplay(ctx)
+ : healthCheck(config.roleplayServiceUrl, "Emily / roleplay-service");
+ case "qscore-service":
+ return ctx
+ ? await runQuinnQScore(ctx)
+ : healthCheck(config.qscoreServiceUrl, "Quinn / qscore-service");
+ default:
+ return {
+ status: "local",
+ summary: `${agent.name} is a local workflow agent managed by Rivet.`,
+ };
+ }
+ } catch (err) {
+ return {
+ status: "unavailable",
+ summary: `${agent.name} could not complete its service handoff: ${err instanceof Error ? err.message : String(err)}`,
+ };
+ }
+}