Orchestration Plan 2
Scope: Build the orchestrator service core — JWT Auth Manager, UI Chat API client, basic HTTP dispatcher, and Postgres orchestration state schema. This is the foundation that all subsequent plans build on.
Spec: Orchestration Spec
Prior plan: Plan 1 (Research — complete)
Task 1 — Scaffold the Orchestrator Railway Service
The orchestrator is a Railway web service (Spec §2.1). It stays running, exposes HTTP endpoints, and is independently deployable. Custom TypeScript — no framework like Mastra (Spec §5, §2.4).
-
Create a new directory at the repo root:
orchestrator/ -
Initialize a TypeScript project:
package.json,tsconfig.json,.gitignore -
Install dependencies:
express,dotenv,pg,cuid2,eventsource(for SSE client) -
Create the entry point:
src/index.ts— Express server listening onPORTenv var -
Add a health endpoint:
GET /health→{ status: "ok", uptime: number } -
Add basic project structure:
-
Add scripts to
package.json:dev(tsx watch),build(tsc),start(node dist) -
Verify the service starts locally and responds on
/health
Deliverable: Scaffolded orchestrator service that starts and responds to health checks.
Task 2 — Implement JWT Auth Manager
The UI Chat API requires JWT session authentication (Spec §1.5). API key auth returns 401. JWTs expire in 15 minutes; refresh tokens in 7 days. The orchestrator must auto-refresh before expiry and re-login if refresh fails.
-
Create
src/auth.tsimplementing theAuthManagerclass with: -
Login flow (
POST /api/auth/login):- Send
{ email, password }to LibreChat - Extract JWT from response body and refreshToken from
Set-Cookieheader - Store both in memory (not filesystem — Spec §2.3)
- Send
-
Refresh flow (
POST /api/auth/refresh):- Send request with
refreshTokencookie, NO Authorization header (Spec §1.5 critical note — sending both causes 401) - Extract new JWT + new refreshToken from response (rotation)
- Replace stored tokens
- Send request with
-
Auto-refresh:
getValidToken()checks if JWT expires in <2 minutes. If so, callsrefresh(). If refresh fails (7-day expiry exceeded), callslogin(). -
On orchestrator restart: full re-login (Spec §2.3 — tokens not persisted to filesystem)
-
User-Agent header: All requests must include a browser User-Agent string (Spec §1.5 — non-browser agents get banned after 2 hours)
-
Role requirement: The service account must have ADMIN role (Spec §1.5 — USER role gets 403)
-
Store LibreChat credentials in Railway env vars:
LIBRECHAT_EMAIL,LIBRECHAT_PASSWORD,LIBRECHAT_URL -
Unit test: verify login → getValidToken → wait → getValidToken triggers refresh
Deliverable: AuthManager that handles the full JWT lifecycle automatically.
Task 3 — Implement UI Chat API Client
All orchestrator-driven agent execution uses the UI Chat API (Spec §1). This client wraps the API calls and SSE subscription.
- Create
src/chat-client.tsimplementingChatClientclass with:invokeAgent(params: InvokeAgentParams) → InvokeAgentResultsubscribeToStream(streamId: string, handlers: SSEHandlers) → SSESubscriptioncheckStatus(conversationId: string) → StatusResultabortGeneration(conversationId: string) → AbortResult
- Invoke agent (
POST /api/agents/chat/agents):- Request body:
{ text, endpoint: "agents", agent_id, conversationId, parentMessageId }(Spec §1.1) - Response:
{ streamId, conversationId, status: "started" }wherestreamId === conversationId(Spec §1.1) - All requests use JWT from AuthManager + browser User-Agent
- Request body:
- SSE subscription (
GET /api/agents/chat/stream/:streamId):- Parse event types:
created,on_run_step,on_run_step_delta,on_run_step_completed,on_message_delta,final(Spec §1.4) - Support
?resume=truefor reconnection (Spec §1.2) - Return subscription object with
disconnect()method
- Parse event types:
- Check status (
GET /api/agents/chat/status/:conversationId):- Poll whether generation is still active
- Abort (
POST /api/agents/chat/abort):- Cancel a running generation (Spec §1.2)
- Create
src/types.tswith all TypeScript interfaces for API requests/responses and SSE events - Integration test: invoke a simple agent, subscribe to stream, capture
finalevent, verify conversationId
Deliverable: ChatClient that can invoke agents, monitor via SSE, check status, and abort.
Task 4 — Implement Postgres Orchestration State
The orchestrator uses the Railway VectorDB Postgres instance with a separate
forgedatabase for structured state (Research §12). This gives 8-9× lower latency than Neon and schema enforcement over MongoDB. Naming conventions aligned with the Neonuvilodatabase (Research §12.5).
-
Create
src/db.tswith Postgres connection (usingpg+ connection pooling) and schema for two tables: -
AgentJob— tracking agent executions (replacesorchestration_jobs):Column Type Description id TEXT Primary key, Cuid2 with agt_prefixconversationId TEXT LibreChat conversation ID agentId TEXT Agent that was invoked taskPrompt TEXT The task given to the agent status TEXT CHECK running|completed|failed|abortedstartedAt TIMESTAMPTZ When the agent was invoked completedAt TIMESTAMPTZ When the agent finished retryCount INT Number of Ralph Wiggum restarts parentJobId TEXT FK For sub-agent tracking (self-ref to AgentJob.id) metadata JSONB Arbitrary key-value createdAt TIMESTAMPTZ Record creation time updatedAt TIMESTAMPTZ Record update time -
Project— mirrored from Phase files for fast queries (replacesorchestration_phase_index):Column Type Description id TEXT Primary key, Cuid2 with prj_prefixprojectName TEXT UNIQUE Project folder name department TEXT Department folder name phase TEXT Current phase status TEXT CHECK draft|review|approved|published|archivedupdatedAt TIMESTAMPTZ Last sync time createdAt TIMESTAMPTZ Record creation time -
Create indexes:
AgentJob_conversationId_idx,AgentJob_status_idx,AgentJob_agentId_idx,Project_department_idx,Project_status_idx -
Connection uses
FORGE_DB_URLenv var:postgresql://postgres:{password}@vectordb.railway.internal:5432/forge -
Add Cuid2 ID generation utility in
src/ids.ts:createJobId()→agt_+ cuid2,createProjectId()→prj_+ cuid2 -
Add helper functions:
createJob(),updateJobStatus(),getJob(),upsertProject(),getActiveProjects() -
Create the
forgedatabase on the Railway VectorDB service:Then connect to
forgeand run the DDL from Research §12.6.
Deliverable: Database layer with job tracking and project index, using the forge Postgres database on Railway VectorDB.
Task 5 — Implement Basic HTTP Dispatcher
The orchestrator exposes
POST /orchestrator/runfor agent invocation (Spec §10.2). This is used by both Inngest schedules and on-demand requests.
- Create
src/dispatcher.tsimplementing the dispatch logic: - Endpoint:
POST /orchestrator/run- Request body:
{ agent_id, task_prompt, mode: "disconnect" | "monitor" }(Spec §10.2) - Validate required fields
- Call
ChatClient.invokeAgent()withconversationId: "new"for fresh conversations - Create an
AgentJobrecord with statusrunning
- Request body:
- Disconnect mode (fire-and-forget):
- Return
{ job_id, conversation_id, status: "started" }immediately - The job is tracked in Postgres; completion is checked via status endpoint or polling
- Return
- Monitor mode (Ralph Wiggum — placeholder for Plan 4):
- For now, same as disconnect mode — return immediately
- Add a TODO comment: “Ralph Wiggum monitoring will be added in Plan 4”
- Add status endpoint:
GET /orchestrator/jobs/:jobId→ job record from Postgres - Add list endpoint:
GET /orchestrator/jobs→ list of recent jobs (optional status filter) - Wire up in
src/index.ts: mount dispatcher routes - Integration test: invoke an agent via the endpoint, verify job is created in Postgres, check status
Deliverable: Working HTTP dispatcher that can invoke agents and track jobs in Postgres.
Task 6 — Deploy Orchestrator to Railway
The orchestrator runs as a Railway web service (Spec §2.1), isolated from LibreChat with direct network access to LibreChat’s API within Railway’s private network.
- Create
Dockerfilefor the orchestrator service - Add a
railway.tomlorrailway.jsonfor Railway configuration - Create the Railway service:
- Name:
forge-orchestrator - Environment variables:
LIBRECHAT_URL(internal Railway URL),LIBRECHAT_EMAIL,LIBRECHAT_PASSWORD,FORGE_DB_URL(Postgres connection string toforgedatabase),PORT LIBRECHAT_URLshould use Railway’s internal DNS (e.g.,http://librechat.railway.internal:3080) for private network accessFORGE_DB_URLshould use Railway’s internal DNS:postgresql://postgres:{password}@vectordb.railway.internal:5432/forge- The
LIBRECHAT_EMAILaccount must have ADMIN role in LibreChat (Spec §1.5) - All HTTP requests from the orchestrator must send a browser User-Agent header to avoid non-browser UA bans (Spec §1.5)
- Name:
- Deploy and verify:
/healthresponds- Auth manager logs in successfully
- Postgres connection to
forgedatabase works
- Test end-to-end:
POST /orchestrator/runwith a simple agent invocation → agent runs → job tracked in Postgres
Deliverable: Orchestrator service deployed on Railway, connected to LibreChat and Postgres.