Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f656aee
docs(spec): ag-ui railway deployment design
blove Jun 5, 2026
5ce6255
test(scripts): failing tests for ag-ui deployment generator
blove Jun 5, 2026
9f1d2c3
feat(scripts): ag-ui deployment generator (server.py, requirements.tx…
blove Jun 5, 2026
912c94f
chore(scripts): drop unused existsSync import
blove Jun 5, 2026
4eb2095
chore(ag-ui-dev): commit generated server.py + requirements.txt + deps/
blove Jun 5, 2026
806308c
feat(ag-ui-dev): Dockerfile + entrypoint watchdog + railway.json
blove Jun 5, 2026
57b78ce
docs(ag-ui-dev): operator README
blove Jun 5, 2026
d1c033f
feat(cockpit/ag-ui): add vercel rewrites to ag-ui-dev railway backend
blove Jun 5, 2026
84199e4
feat(cockpit/ag-ui/interrupts): vercel edge middleware (origin + rate…
blove Jun 5, 2026
7468ea6
fix(cockpit/ag-ui/interrupts): hoist middleware deps to root per cock…
blove Jun 5, 2026
0ae830b
feat(cockpit/ag-ui/streaming): vercel edge middleware (origin + rate …
blove Jun 5, 2026
43682cc
ci: deploy ag-ui-dev to Railway on push to main
blove Jun 5, 2026
4231745
test(smoke): probe ag-ui-dev railway healthcheck + examples routes
blove Jun 5, 2026
92892bc
docs(plan): ag-ui railway deployment implementation plan
blove Jun 5, 2026
4733121
fix(ag-ui-dev): exclude nx project.json + tsconfig from staged deps
blove Jun 5, 2026
2dfad7e
fix(ag-ui): use actual Railway domain (ag-ui-dev-production.up.railwa…
blove Jun 6, 2026
a76e0e1
fix(ag-ui-dev): emit only direct deps in requirements.txt
blove Jun 6, 2026
cb5a0dc
fix(ag-ui-dev): return JSONResponse from auth middleware (not HTTPExc…
blove Jun 6, 2026
759cdcc
fix(ag-ui): integrate Vercel side via assemble-examples, not per-exam…
blove Jun 6, 2026
f471e26
docs(plan): postscript documenting rework + bugs caught during execution
blove Jun 6, 2026
f62e584
Merge branch 'main' into claude/ag-ui-railway-deployment
blove Jun 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/deploy-ag-ui.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Deploy AG-UI Railway

on:
push:
branches: [main]
paths:
- 'cockpit/ag-ui/**/python/**'
- 'apps/cockpit/scripts/capability-registry.ts'
- 'scripts/generate-ag-ui-deployment-config.ts'
- 'deployments/ag-ui-dev/**'
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: read

env:
DO_NOT_TRACK: '1'

jobs:
deploy:
name: Deploy ag-ui-dev to Railway
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2

- uses: actions/setup-node@v6.3.0
with:
node-version: 22
cache: npm

- run: npm ci

- name: Regenerate deployment artifacts
run: npx tsx scripts/generate-ag-ui-deployment-config.ts

- name: Drift check — committed artifacts must match regeneration
run: |
if ! git diff --exit-code -- deployments/ag-ui-dev/; then
echo "::error::deployments/ag-ui-dev/ is out of sync. Run 'npx tsx scripts/generate-ag-ui-deployment-config.ts' locally and commit the result."
exit 1
fi
- name: Install Railway CLI
run: npm install -g @railway/cli@4

- name: Deploy
working-directory: deployments/ag-ui-dev
run: railway up --service ag-ui-dev --detach
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
node_modules/
dist/
.next/
deploy/

# Nx
.nx/cache/
Expand Down Expand Up @@ -44,6 +45,9 @@ __pycache__/

# Local LangGraph deployment deps
deployments/*/deps/
# ...but ag-ui-dev commits generated deps/ (Docker build copies them in)
!deployments/ag-ui-dev/deps/
!deployments/ag-ui-dev/deps/**

# Generated license public key (produced by libs/licensing/scripts/generate-public-key.mjs)
libs/licensing/src/lib/license-public-key.generated.ts
Expand Down
20 changes: 20 additions & 0 deletions apps/cockpit/e2e/production-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,23 @@ test.describe('Production: canonical demo sends runtime telemetry', () => {
);
});
});

test.describe('ag-ui Railway runtime', () => {
const RAILWAY_URL = process.env['AG_UI_RAILWAY_URL'] ?? 'https://ag-ui-dev-production.up.railway.app';

test('healthcheck /ok responds 200', async ({ request }) => {
const res = await request.get(`${RAILWAY_URL}/ok`);
expect(res.status()).toBe(200);
expect(await res.json()).toMatchObject({ ok: true });
});

test('examples.threadplane.ai/ag-ui/interrupts is reachable', async ({ page }) => {
const res = await page.goto(`${EXAMPLES_URL}/ag-ui/interrupts/`);
expect(res?.status()).toBeLessThan(400);
});

test('examples.threadplane.ai/ag-ui/streaming is reachable', async ({ page }) => {
const res = await page.goto(`${EXAMPLES_URL}/ag-ui/streaming/`);
expect(res?.status()).toBeLessThan(400);
});
});
4 changes: 2 additions & 2 deletions cockpit/ag-ui/interrupts/angular/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
"maximumWarning": "1mb",
"maximumError": "1.5mb"
},
{
"type": "anyComponentStyle",
Expand Down
6 changes: 6 additions & 0 deletions cockpit/ag-ui/interrupts/angular/vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "npx nx build cockpit-ag-ui-interrupts-angular",
"outputDirectory": "dist/cockpit/ag-ui/interrupts/angular/browser",
"framework": null
}
4 changes: 2 additions & 2 deletions cockpit/ag-ui/streaming/angular/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
"maximumWarning": "1mb",
"maximumError": "1.5mb"
},
{
"type": "anyComponentStyle",
Expand Down
2 changes: 2 additions & 0 deletions deployments/ag-ui-dev/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.env
*.log
25 changes: 25 additions & 0 deletions deployments/ag-ui-dev/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Multi-stage build for the ag-ui-dev FastAPI runtime.
# Stage 1 installs Python deps into a user-site so we can copy them
# into a slim runner without build tooling.
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

FROM python:3.12-slim
WORKDIR /app

# curl is needed by entrypoint.sh's watchdog.
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*

COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1

COPY . .
RUN chmod +x entrypoint.sh

EXPOSE 8000
CMD ["./entrypoint.sh"]
53 changes: 53 additions & 0 deletions deployments/ag-ui-dev/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# ag-ui-dev

Multi-topic FastAPI app hosting the cockpit `ag-ui/*` runtimes on Railway.

## What's in here

| File / dir | Source | Edit? |
| --- | --- | --- |
| `Dockerfile`, `entrypoint.sh`, `railway.json`, `README.md` | hand-written | yes |
| `server.py`, `requirements.txt`, `deps/` | generated by `scripts/generate-ag-ui-deployment-config.ts` | no |

The generator reads `apps/cockpit/scripts/capability-registry.ts`, filters to
`product === 'ag-ui'` entries with a `pythonDir`, stages each into `deps/<topic>/`,
and writes one `add_langgraph_fastapi_endpoint(..., path="/agent/<topic>")` per
topic in `server.py`.

## Regenerate locally

```bash
npx tsx scripts/generate-ag-ui-deployment-config.ts
```

CI runs `git diff --exit-code deployments/ag-ui-dev/` after regenerating, so a
stale commit fails the deploy workflow.

## Local dev story

`deployments/ag-ui-dev/` is the deployment artifact, not the dev surface. To
run a topic locally, use its own uvicorn entrypoint:

```bash
cd cockpit/ag-ui/interrupts/python
uv run uvicorn src.app:app --port 5320
```

## Required env vars (on Railway)

| Var | Purpose |
| --- | --- |
| `OPENAI_API_KEY` | LLM calls inside the graphs |
| `AG_UI_INTERNAL_TOKEN` | Must match the Vercel edge middleware's injected `X-Internal-Token` header |
| `PORT` | Provided by Railway automatically |

## Rotating `AG_UI_INTERNAL_TOKEN`

The token lives in two places: Railway (this service) and each Vercel project
that hosts a cockpit ag-ui example. Rotation order:

1. Generate a new token: `openssl rand -hex 32`.
2. Update the Vercel env var on every ag-ui cockpit project. Redeploy each.
3. Update the Railway env var. Redeploy the service.

Tokens valid in both places overlap briefly during step 2.
2 changes: 2 additions & 0 deletions deployments/ag-ui-dev/deps/interrupts/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
.venv/
139 changes: 139 additions & 0 deletions deployments/ag-ui-dev/deps/interrupts/docs/guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Human-in-the-Loop Interrupts with AG-UI and Angular

<Summary>
Build a chat interface with human-in-the-loop approval using `provideAgent()` and
`injectAgent()` from `@threadplane/ag-ui`. The LangGraph backend pauses execution for approval
and emits an AG-UI `CUSTOM` `on_interrupt` event; the frontend resumes it with `stream.submit()`.
</Summary>

<Prompt>
Add human-in-the-loop approval to this Angular component using `provideAgent()` and `injectAgent()` from `@threadplane/ag-ui`. Use `stream.interrupt()` to display pending approvals, `stream.submit({ resume: true })` to approve and resume execution, and `stream.submit({ resume: false })` to reject. Bind `stream.messages()` in the template via the `<chat>` component from `@threadplane/chat`.
</Prompt>

<Steps>
<Step title="Configure the provider">

Set up `provideAgent()` in your app config with the AG-UI backend URL:

```typescript
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/ag-ui';

export const appConfig: ApplicationConfig = {
providers: [
provideAgent({
url: 'http://localhost:5320/agent',
}),
],
};
```

This makes the configured agent available to all `injectAgent()` calls in your app.

</Step>
<Step title="Create the streaming resource">

In your component, call `injectAgent()` to retrieve the configured interrupts agent:

```typescript
// interrupts.component.ts
import { injectAgent } from '@threadplane/ag-ui';

export class InterruptsComponent {
protected readonly stream = injectAgent();
}
```

The resource automatically handles streaming, interrupt detection, and state management.

</Step>
<Step title="Handle interrupts in the template">

Use `stream.interrupt()` to conditionally show a pending approval in the sidebar:

```html
<chat [agent]="stream" />

@if (stream.interrupt(); as interrupt) {
<aside>
<p>{{ interrupt.value | json }}</p>
<button (click)="approve()">Approve</button>
<button (click)="reject()">Reject</button>
</aside>
} @else {
<p>No pending approvals</p>
}
```

When the graph pauses, `stream.interrupt()` returns the interrupt payload. When no interrupt is active, it returns a falsy value.

</Step>
<Step title="Implement approve and reject logic">

Add methods that resume graph execution with the user's decision:

```typescript
approve(): void {
this.stream.submit({ resume: true });
}

reject(): void {
this.stream.submit({ resume: false });
}
```

Submitting a `resume` payload continues past an interrupt. Submitting `{ resume: false }` signals rejection so the graph can handle it accordingly.

<Tip>
You can extend this pattern to pass structured data back to the graph. For example, `stream.submit({ resume: true, edits: { ... } })` lets the user modify the response before approving.
</Tip>

</Step>
<Step title="The AG-UI backend with ag-ui-langgraph">

The backend wraps the LangGraph `graph` (which uses `interrupt()` from `langgraph.types`) in a
FastAPI app using `ag-ui-langgraph`. When `interrupt()` fires, the package emits an AG-UI `CUSTOM`
`on_interrupt` event that the `@threadplane/ag-ui` adapter surfaces as `stream.interrupt()`.

```python
# server.py
from fastapi import FastAPI
from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint
from .graph import graph

agent = LangGraphAgent(name="interrupts", graph=graph)
app = FastAPI(title="cockpit-ag-ui-interrupts")
add_langgraph_fastapi_endpoint(app, agent, path="/agent")

@app.get("/ok")
def ok() -> dict:
return {"ok": True}
```

Run the backend with:

```bash
uv run uvicorn src.server:app --port 5320
```

<Warning>
A checkpointer is required for interrupts to work. Without it, the graph cannot save its state
while paused. The graph in `src/graph.py` uses `MemorySaver` for development.
</Warning>

</Step>
</Steps>

<Tip>
The `<chat>` component handles message rendering, input, loading states, and error display. Focus your component on interrupt handling logic.
</Tip>

<Warning>
Never expose your LangSmith API key in client-side code. Use server-side environment variables or a proxy.
</Warning>

<Related>
- [LangGraph Interrupts](/langgraph/core-capabilities/interrupts/overview/python) — The LangGraph variant of this pattern using the LangGraph SDK directly
- [AG-UI Streaming](/ag-ui/core-capabilities/streaming/overview/python) — Basic streaming without interrupts using the AG-UI adapter
</Related>
14 changes: 14 additions & 0 deletions deployments/ag-ui-dev/deps/interrupts/prompts/interrupts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Refund Authorization Assistant

You help authorize customer refunds. Every refund must be reviewed by a human
operator before any charge is reversed.

When the user describes a refund situation, acknowledge what you understood:
- The customer identifier they mentioned (or note it's not specified).
- The refund amount in USD (or note it's not specified).
- A short reason — one sentence describing what makes this refund justified.

Then state that you're pausing for operator approval. Do not claim the refund
has been issued — that only happens after approval, in a later step.

Keep your response short. The approval card surfaces structured fields.
19 changes: 19 additions & 0 deletions deployments/ag-ui-dev/deps/interrupts/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[project]
name = "cockpit-ag-ui-interrupts"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"langgraph>=0.3",
"langchain-openai>=0.3",
"langsmith>=0.2",
"ag-ui-langgraph>=0.0.25",
"fastapi>=0.110",
"uvicorn[standard]>=0.29",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src"]
Loading
Loading