Skip to content
archived Visibility internal Owner erik@uvilo.com Approver _ Created 2026-06-15 Updated 2026-06-27

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).

  1. Create a new directory at the repo root: orchestrator/

  2. Initialize a TypeScript project: package.json, tsconfig.json, .gitignore

  3. Install dependencies: express, dotenv, pg, cuid2, eventsource (for SSE client)

  4. Create the entry point: src/index.ts — Express server listening on PORT env var

  5. Add a health endpoint: GET /health{ status: "ok", uptime: number }

  6. Add basic project structure:

    orchestrator/
    ├── src/
    │   ├── index.ts          # Express server + startup
    │   ├── auth.ts           # JWT Auth Manager
    │   ├── chat-client.ts    # UI Chat API client
    │   ├── dispatcher.ts     # Agent dispatch logic
    │   ├── db.ts             # Postgres connection + schema
    │   └── types.ts          # Shared types
    ├── package.json
    ├── tsconfig.json
    └── .gitignore
  7. Add scripts to package.json: dev (tsx watch), build (tsc), start (node dist)

  8. 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.

  1. Create src/auth.ts implementing the AuthManager class with:

    login(email, password) → { jwt, refreshToken }
    refresh(refreshToken) → { jwt, refreshToken }
    getValidToken() → jwt   // auto-refreshes if <2min remaining
  2. Login flow (POST /api/auth/login):

    • Send { email, password } to LibreChat
    • Extract JWT from response body and refreshToken from Set-Cookie header
    • Store both in memory (not filesystem — Spec §2.3)
  3. Refresh flow (POST /api/auth/refresh):

    • Send request with refreshToken cookie, NO Authorization header (Spec §1.5 critical note — sending both causes 401)
    • Extract new JWT + new refreshToken from response (rotation)
    • Replace stored tokens
  4. Auto-refresh: getValidToken() checks if JWT expires in <2 minutes. If so, calls refresh(). If refresh fails (7-day expiry exceeded), calls login().

  5. On orchestrator restart: full re-login (Spec §2.3 — tokens not persisted to filesystem)

  6. User-Agent header: All requests must include a browser User-Agent string (Spec §1.5 — non-browser agents get banned after 2 hours)

  7. Role requirement: The service account must have ADMIN role (Spec §1.5 — USER role gets 403)

  8. Store LibreChat credentials in Railway env vars: LIBRECHAT_EMAIL, LIBRECHAT_PASSWORD, LIBRECHAT_URL

  9. 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.

  1. Create src/chat-client.ts implementing ChatClient class with:
    • invokeAgent(params: InvokeAgentParams) → InvokeAgentResult
    • subscribeToStream(streamId: string, handlers: SSEHandlers) → SSESubscription
    • checkStatus(conversationId: string) → StatusResult
    • abortGeneration(conversationId: string) → AbortResult
  2. Invoke agent (POST /api/agents/chat/agents):
    • Request body: { text, endpoint: "agents", agent_id, conversationId, parentMessageId } (Spec §1.1)
    • Response: { streamId, conversationId, status: "started" } where streamId === conversationId (Spec §1.1)
    • All requests use JWT from AuthManager + browser User-Agent
  3. 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=true for reconnection (Spec §1.2)
    • Return subscription object with disconnect() method
  4. Check status (GET /api/agents/chat/status/:conversationId):
    • Poll whether generation is still active
  5. Abort (POST /api/agents/chat/abort):
    • Cancel a running generation (Spec §1.2)
  6. Create src/types.ts with all TypeScript interfaces for API requests/responses and SSE events
  7. Integration test: invoke a simple agent, subscribe to stream, capture final event, 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 forge database for structured state (Research §12). This gives 8-9× lower latency than Neon and schema enforcement over MongoDB. Naming conventions aligned with the Neon uvilo database (Research §12.5).

  1. Create src/db.ts with Postgres connection (using pg + connection pooling) and schema for two tables:

  2. AgentJob — tracking agent executions (replaces orchestration_jobs):

    ColumnTypeDescription
    idTEXTPrimary key, Cuid2 with agt_ prefix
    conversationIdTEXTLibreChat conversation ID
    agentIdTEXTAgent that was invoked
    taskPromptTEXTThe task given to the agent
    statusTEXT CHECKrunning | completed | failed | aborted
    startedAtTIMESTAMPTZWhen the agent was invoked
    completedAtTIMESTAMPTZWhen the agent finished
    retryCountINTNumber of Ralph Wiggum restarts
    parentJobIdTEXT FKFor sub-agent tracking (self-ref to AgentJob.id)
    metadataJSONBArbitrary key-value
    createdAtTIMESTAMPTZRecord creation time
    updatedAtTIMESTAMPTZRecord update time
  3. Project — mirrored from Phase files for fast queries (replaces orchestration_phase_index):

    ColumnTypeDescription
    idTEXTPrimary key, Cuid2 with prj_ prefix
    projectNameTEXT UNIQUEProject folder name
    departmentTEXTDepartment folder name
    phaseTEXTCurrent phase
    statusTEXT CHECKdraft | review | approved | published | archived
    updatedAtTIMESTAMPTZLast sync time
    createdAtTIMESTAMPTZRecord creation time
  4. Create indexes: AgentJob_conversationId_idx, AgentJob_status_idx, AgentJob_agentId_idx, Project_department_idx, Project_status_idx

  5. Connection uses FORGE_DB_URL env var: postgresql://postgres:{password}@vectordb.railway.internal:5432/forge

  6. Add Cuid2 ID generation utility in src/ids.ts: createJobId()agt_ + cuid2, createProjectId()prj_ + cuid2

  7. Add helper functions: createJob(), updateJobStatus(), getJob(), upsertProject(), getActiveProjects()

  8. Create the forge database on the Railway VectorDB service:

    CREATE DATABASE forge;

    Then connect to forge and 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/run for agent invocation (Spec §10.2). This is used by both Inngest schedules and on-demand requests.

  1. Create src/dispatcher.ts implementing the dispatch logic:
  2. Endpoint: POST /orchestrator/run
    • Request body: { agent_id, task_prompt, mode: "disconnect" | "monitor" } (Spec §10.2)
    • Validate required fields
    • Call ChatClient.invokeAgent() with conversationId: "new" for fresh conversations
    • Create an AgentJob record with status running
  3. 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
  4. 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”
  5. Add status endpoint: GET /orchestrator/jobs/:jobId → job record from Postgres
  6. Add list endpoint: GET /orchestrator/jobs → list of recent jobs (optional status filter)
  7. Wire up in src/index.ts: mount dispatcher routes
  8. 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.

  1. Create Dockerfile for the orchestrator service
  2. Add a railway.toml or railway.json for Railway configuration
  3. Create the Railway service:
    • Name: forge-orchestrator
    • Environment variables: LIBRECHAT_URL (internal Railway URL), LIBRECHAT_EMAIL, LIBRECHAT_PASSWORD, FORGE_DB_URL (Postgres connection string to forge database), PORT
    • LIBRECHAT_URL should use Railway’s internal DNS (e.g., http://librechat.railway.internal:3080) for private network access
    • FORGE_DB_URL should use Railway’s internal DNS: postgresql://postgres:{password}@vectordb.railway.internal:5432/forge
    • The LIBRECHAT_EMAIL account 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)
  4. Deploy and verify:
    • /health responds
    • Auth manager logs in successfully
    • Postgres connection to forge database works
  5. Test end-to-end: POST /orchestrator/run with a simple agent invocation → agent runs → job tracked in Postgres

Deliverable: Orchestrator service deployed on Railway, connected to LibreChat and Postgres.