Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
89 changes: 89 additions & 0 deletions .github/workflows/deploy_runtime.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Deploys the shared backend (runtime/) to Cloud Run on every change to it on main.
# One-time setup of Google Cloud: runtime/deploy/setup_gcp.sh, which also lists the
# GitHub secrets this workflow needs.
name: deploy_runtime

on:
push:
branches: [main]
paths:
- "runtime/**"
- ".github/workflows/deploy_runtime.yaml"
workflow_dispatch:

# Two deploys at once would race on the same service.
concurrency:
group: deploy_runtime
cancel-in-progress: false

permissions:
contents: read
id-token: write # lets the job sign in to Google Cloud without a key file

env:
PROJECT_ID: gen-lang-client-0563181971
REGION: us-central1
SERVICE: calaverita-runtime
REGISTRY_REPOSITORY: calaverita
RUNTIME_SERVICE_ACCOUNT: calaverita-runtime@gen-lang-client-0563181971.iam.gserviceaccount.com

jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actions are pinned by tag (@v4, @v2, @v3) in a workflow that has id-token: write in a public repo, and tags can move. Suggestion: pin every uses: to a commit SHA (keep the version in a comment) and add Dependabot for github-actions so the pins keep moving.


- name: Sign in to Google Cloud
id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
token_format: access_token

- name: Sign in to Artifact Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGION }}-docker.pkg.dev
username: oauth2accesstoken
password: ${{ steps.auth.outputs.access_token }}

- name: Build and push the image
id: image
env:
IMAGE: ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REGISTRY_REPOSITORY }}/runtime:${{ github.sha }}
run: |
docker build --tag "$IMAGE" runtime
docker push "$IMAGE"
echo "name=$IMAGE" >> "$GITHUB_OUTPUT"

# The key and the password are repository secrets. GitHub masks them in the
# logs; Cloud Run keeps them as environment variables of the revision.
- name: Deploy to Cloud Run
id: deploy
uses: google-github-actions/deploy-cloudrun@v2
with:
project_id: ${{ env.PROJECT_ID }}
region: ${{ env.REGION }}
service: ${{ env.SERVICE }}
image: ${{ steps.image.outputs.name }}
env_vars: |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key and the password end up as plain environment variables of the revision, readable by anyone with viewer access on the project. Secret Manager (secrets: here, secretAccessor for the runtime service account) would be better. Fine as a follow-up for a throwaway project.

CLAUDE_API_KEY=${{ secrets.CLAUDE_API_KEY }}
WORKSHOP_CODE=${{ secrets.WORKSHOP_CODE }}
flags: >-
--service-account=${{ env.RUNTIME_SERVICE_ACCOUNT }}
--allow-unauthenticated
--max-instances=1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cold start. With min instances at 0, the first request after a quiet spell waits for a cold start (tsx plus the runtime). Here the first call took longer than doctor.mjs's 8 second timeout and it reported "Can't reach the backend" before the instance was warm; workshop participants would see the same lag on their first message.

I'm not suggesting an always-on instance because of the cost. Cheaper options: a manually started workflow that pings /info every few minutes during the event window (Cloud Run stops idle instances after a while, so one warm-up isn't enough), --cpu-boost, and a longer timeout with a retry in doctor.mjs/setup.mjs for hosted backends.

--memory=512Mi
--timeout=900

# The password gate is the point of this deployment, so check it is on.
- name: Smoke test
env:
BASE: ${{ steps.deploy.outputs.url }}/api/copilotkit
run: |
curl --fail --silent --show-error --retry 5 --retry-delay 5 --retry-all-errors "$BASE/info" | jq -e '.agents.default'
status=$(curl --silent --output /dev/null --write-out '%{http_code}' --request POST "$BASE/agent/default/run" --data '{}')
test "$status" = 401 || { echo "Expected 401 without the workshop code, got $status"; exit 1; }
echo "Deployed: $BASE"
33 changes: 33 additions & 0 deletions .github/workflows/runtime_ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Checks a change to the shared backend before it can reach main.
name: runtime_ci

on:
pull_request:
paths:
- "runtime/**"
- ".github/workflows/runtime_ci.yaml"

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: runtime/package-lock.json

- name: Typecheck
working-directory: runtime
run: |
npm ci --no-audit --no-fund
npm run typecheck

- name: Build the image
run: docker build --tag calaverita-runtime runtime
1 change: 1 addition & 0 deletions 01-shared-state/apps/calaverita_app/lib/app/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class App extends StatelessWidget {
Widget build(BuildContext context) {
return CopilotKit(
runtimeUrl: AppConfig.runtimeUrl,
headers: AppConfig.headers,
child: MaterialApp(
title: 'Calaverita Generator',
debugShowCheckedModeBanner: false,
Expand Down
29 changes: 29 additions & 0 deletions 01-shared-state/apps/calaverita_app/lib/app/app_config.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:math';

/// The one place that says where the agent lives.
///
/// To use the shared hosted backend, change [defaultRuntimeUrl] (or run with
Expand All @@ -24,4 +26,31 @@ class AppConfig {
'AGENT_ID',
defaultValue: 'default',
);

/// The password of the shared backend, when it asks for one.
///
/// Run with `--dart-define=WORKSHOP_CODE=<code>`. A local runtime asks for
/// nothing, so leave it out there.
static const workshopCode = String.fromEnvironment('WORKSHOP_CODE');

/// A random id for this run of the app, so a server can tell sessions apart.
static final String sessionId = _randomId();

/// The headers sent with every run.
///
/// Custom names on purpose: a runtime answers the browser's preflight with
/// `Access-Control-Allow-Headers: *`, which covers them but not
/// `Authorization`. A server that does not know them ignores them.
static Future<Map<String, String>> headers() async => {
'X-Workshop-Session': sessionId,
if (workshopCode.isNotEmpty) 'X-Workshop-Code': workshopCode,
};

static String _randomId() {
final random = Random.secure();
return List.generate(
16,
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
}
}
1 change: 1 addition & 0 deletions 02-generative-ui/apps/calaverita_app/lib/app/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class App extends StatelessWidget {
Widget build(BuildContext context) {
return CopilotKit(
runtimeUrl: AppConfig.runtimeUrl,
headers: AppConfig.headers,
child: MaterialApp(
title: 'Calaverita Generator',
debugShowCheckedModeBanner: false,
Expand Down
29 changes: 29 additions & 0 deletions 02-generative-ui/apps/calaverita_app/lib/app/app_config.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:math';

/// The one place that says where the agent lives.
///
/// To use the shared hosted backend, change [defaultRuntimeUrl] (or run with
Expand All @@ -24,4 +26,31 @@ class AppConfig {
'AGENT_ID',
defaultValue: 'default',
);

/// The password of the shared backend, when it asks for one.
///
/// Run with `--dart-define=WORKSHOP_CODE=<code>`. A local runtime asks for
/// nothing, so leave it out there.
static const workshopCode = String.fromEnvironment('WORKSHOP_CODE');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The password is compiled into the build with --dart-define, so anyone who has the web build (or opens devtools) can read it. Fine for running locally, but if hosted web builds are planned they must not be built with the code, or the gate does nothing. Worth a line in the README ("a build made with WORKSHOP_CODE contains the password"). An in-app prompt would avoid it, but it touches three snapshots and adds noise to the diffs participants read.


/// A random id for this run of the app, so a server can tell sessions apart.
static final String sessionId = _randomId();

/// The headers sent with every run.
///
/// Custom names on purpose: a runtime answers the browser's preflight with
/// `Access-Control-Allow-Headers: *`, which covers them but not

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question, low priority: the preflight answers Access-Control-Allow-Headers: *, and Chrome accepts the custom headers (checked). The 03 server lists them explicitly, the runtime relies on the wildcard. I'm not sure every browser honors the wildcard, so a quick check in Safari or Firefox would settle it if the app will be opened there.

/// `Authorization`. A server that does not know them ignores them.
static Future<Map<String, String>> headers() async => {
'X-Workshop-Session': sessionId,
if (workshopCode.isNotEmpty) 'X-Workshop-Code': workshopCode,
};

static String _randomId() {
final random = Random.secure();
return List.generate(
16,
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
}
}
1 change: 1 addition & 0 deletions 03-custom-server/apps/calaverita_app/lib/app/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class App extends StatelessWidget {
Widget build(BuildContext context) {
return CopilotKit(
runtimeUrl: AppConfig.runtimeUrl,
headers: AppConfig.headers,
child: MaterialApp(
title: 'Calaverita Generator',
debugShowCheckedModeBanner: false,
Expand Down
29 changes: 29 additions & 0 deletions 03-custom-server/apps/calaverita_app/lib/app/app_config.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:math';

/// The one place that says where the agent lives.
///
/// To use a hosted server, change [defaultRuntimeUrl] (or run with
Expand All @@ -24,4 +26,31 @@ class AppConfig {
'AGENT_ID',
defaultValue: 'calaverita',
);

/// The password of a hosted server, when it asks for one.
///
/// Run with `--dart-define=WORKSHOP_CODE=<code>`. A local server asks for
/// nothing, so leave it out there.
static const workshopCode = String.fromEnvironment('WORKSHOP_CODE');

/// A random id for this run of the app, so a server can tell sessions apart.
static final String sessionId = _randomId();

/// The headers sent with every run.
///
/// Custom names on purpose: a runtime answers the browser's preflight with
/// `Access-Control-Allow-Headers: *`, which covers them but not
/// `Authorization`. A server that does not know them ignores them.
static Future<Map<String, String>> headers() async => {
'X-Workshop-Session': sessionId,
if (workshopCode.isNotEmpty) 'X-Workshop-Code': workshopCode,
};

static String _randomId() {
final random = Random.secure();
return List.generate(
16,
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
}
}
2 changes: 1 addition & 1 deletion 03-custom-server/server/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Required. Claude API key. Copy this file to .env and fill it in. Never commit .env.
ANTHROPIC_API_KEY=
CLAUDE_API_KEY=
# Optional. Model id (default claude-sonnet-5-5).
# CLAUDE_MODEL=claude-sonnet-5-5
# Optional. Server port (default 4100).
Expand Down
8 changes: 5 additions & 3 deletions 03-custom-server/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@ import { CopilotRuntime, createCopilotHonoHandler, InMemoryAgentRunner } from "@
import { CalaveritaAgent, MODEL } from "./agent.js";

process.env.COPILOTKIT_TELEMETRY_DISABLED ??= "true";
if (!process.env.ANTHROPIC_API_KEY) {
console.error("ANTHROPIC_API_KEY is not set. Copy .env.example to .env and put your key in it.");
if (!process.env.CLAUDE_API_KEY) {
console.error("CLAUDE_API_KEY is not set. Copy .env.example to .env and put your key in it.");
process.exit(1);
}
// The Anthropic SDK reads ANTHROPIC_API_KEY, so hand it the key.
process.env.ANTHROPIC_API_KEY = process.env.CLAUDE_API_KEY;

const runtime = new CopilotRuntime({
agents: { calaverita: new CalaveritaAgent() },
runner: new InMemoryAgentRunner(),
});

const app = new Hono();
app.use("*", cors({ origin: "*", allowHeaders: ["authorization", "content-type"] }));
app.use("*", cors({ origin: "*", allowHeaders: ["authorization", "content-type", "x-workshop-code", "x-workshop-session"] }));
app.get("/health", (c) => c.text("ok"));
app.route("/", createCopilotHonoHandler({ runtime, basePath: "/api/copilotkit" }));

Expand Down
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ node tool/setup.mjs
It installs Flutter 3.47.0 through FVM, checks your machine, asks which step to
run and which backend to use, and starts the app in Chrome. The backend is one of:

- **The shared backend:** nothing to set up.
- **The shared backend:** the one we host for the workshop, at
`https://calaverita-runtime-641689536796.us-central1.run.app/api/copilotkit`.
Nothing to set up except the workshop password, which you get at the workshop.
It asks for it (hidden as you type), or pass `--code <password>`.
- **Your own runtime:** it asks for your Anthropic API key (hidden as you type)
and starts the runtime in `runtime/` for you.
- **A custom backend:** any CopilotKit runtime you have a URL for, for example one
Expand Down Expand Up @@ -90,7 +93,7 @@ npm install
```

Create your `runtime/.env` from the example, then open it and set
`ANTHROPIC_API_KEY=<your key>`:
`CLAUDE_API_KEY=<your key>`:

```sh
cp .env.example .env # macOS and Linux
Expand Down Expand Up @@ -167,7 +170,7 @@ Start the server in a first terminal. It uses port 4100, so the runtime of steps

```sh
cd 03-custom-server/server
cp .env.example .env # set ANTHROPIC_API_KEY
cp .env.example .env # set CLAUDE_API_KEY
npm install
npm start # http://localhost:4100/api/copilotkit
```
Expand All @@ -187,6 +190,21 @@ The runtime URL is set in **one place per snapshot**:
`AppConfig.defaultRuntimeUrl`, or run with
`--dart-define=RUNTIME_URL=<url>`.

The shared backend (`https://calaverita-runtime-641689536796.us-central1.run.app/api/copilotkit`)
also asks for a password. The app sends it, and a random id
of the run, as the `X-Workshop-Code` and `X-Workshop-Session` headers on every
request:

```sh
fvm flutter run -d chrome \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The \ line continuation doesn't work in PowerShell (it uses a backtick), and the README says Windows is supported. A single line works everywhere.

--dart-define=RUNTIME_URL=<url> --dart-define=WORKSHOP_CODE=<password>
```

A runtime on your machine ignores both headers, so leave `WORKSHOP_CODE` out
there. To host the shared backend, deploy `runtime/` and set `WORKSHOP_CODE` and
`CLAUDE_API_KEY` on the host. The runtime then answers `401` without the
password. `GET /info` and the CORS preflight stay open.

## How the code is organized

Each step is a Dart workspace of small packages:
Expand Down Expand Up @@ -225,6 +243,8 @@ packages.
- **The runtime starts but every message fails**: the model or the key is wrong.
Send a run straight to the runtime and read the `RUN_ERROR` event (see the
[SDK example README](https://github.com/VGVentures/copilotkit_flutter_sdk/blob/main/example/README.md#troubleshooting)).
- **"The copilot could not answer" on the shared backend**: the password is
missing or wrong (`401`).
- **`pub get` can't fetch the SDK**: you need access to the SDK repository.

## License
Expand Down
6 changes: 6 additions & 0 deletions runtime/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# The key lives on the host, never in an image.
.env
node_modules
deploy
Dockerfile
.dockerignore
6 changes: 5 additions & 1 deletion runtime/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Copy this file to .env (git-ignored) and fill in your key.
COPILOTKIT_MODEL=anthropic:claude-sonnet-5-5
ANTHROPIC_API_KEY=
CLAUDE_API_KEY=

# Only for a shared, hosted runtime: the password the participants send. Leave it
# out on your machine.
# WORKSHOP_CODE=
8 changes: 8 additions & 0 deletions runtime/.gcloudignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# What `gcloud run deploy --source runtime` uploads. The key lives on the host,
# never in the upload, and the image installs its own node_modules.
*
!Dockerfile
!package.json
!package-lock.json
!server.ts
!env.ts
16 changes: 16 additions & 0 deletions runtime/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# The image of the shared backend. Build it from this folder:
# docker build -t calaverita-runtime runtime
FROM node:22-slim

WORKDIR /app
ENV NODE_ENV=production

# `npm start` runs the TypeScript with tsx, a dev dependency, so install all of them.
COPY package.json package-lock.json ./
RUN npm ci --include=dev --no-audit --no-fund

COPY server.ts env.ts ./

USER node
# Cloud Run sets PORT; server.ts reads it and listens on 0.0.0.0.
CMD ["npm", "start", "--silent"]
Loading
Loading