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
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
90 changes: 90 additions & 0 deletions .github/workflows/deploy_runtime.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# 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@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: Sign in to Google Cloud
id: auth
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # 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@c94ce9fb468520275223c153574b00df6fe4bcc9 # 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 stay in Secret Manager: Cloud Run hands them to the
# revision as environment variables, read as the runtime service account.
- name: Deploy to Cloud Run
id: deploy
uses: google-github-actions/deploy-cloudrun@251330ba9a8a34bfbc1622895f42e1d53fd14522 # v2
with:
project_id: ${{ env.PROJECT_ID }}
region: ${{ env.REGION }}
service: ${{ env.SERVICE }}
image: ${{ steps.image.outputs.name }}
secrets: |
ANTHROPIC_API_KEY=anthropic-api-key:latest
WORKSHOP_CODE=workshop-code:latest
flags: >-
--service-account=${{ env.RUNTIME_SERVICE_ACCOUNT }}
--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.

--cpu-boost
--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' \
|| { echo "If this is a 403, the service is not public yet: run runtime/deploy/setup_gcp.sh again."; exit 1; }
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"
37 changes: 37 additions & 0 deletions .github/workflows/runtime_ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 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@11d5960a326750d5838078e36cf38b85af677262 # v4

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # 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: Test the password gate
working-directory: runtime
run: npm test

- name: Build the image
run: docker build --tag calaverita-runtime runtime
31 changes: 31 additions & 0 deletions .github/workflows/warm_runtime.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Keeps the shared backend awake for a while, so the first participants don't wait
# for a cold start. Run it by hand from the Actions tab shortly before the event.
name: warm_runtime

on:
workflow_dispatch:
inputs:
url:
description: Runtime URL
default: https://calaverita-runtime-641689536796.us-central1.run.app/api/copilotkit
minutes:
description: How long to keep it warm
default: "120"

permissions: {}

jobs:
warm:
runs-on: ubuntu-latest
timeout-minutes: 190
steps:
- name: Ping /info every 4 minutes
env:
URL: ${{ inputs.url }}
MINUTES: ${{ inputs.minutes }}
run: |
end=$(( $(date +%s) + MINUTES * 60 ))
while [ "$(date +%s)" -lt "$end" ]; do
curl --silent --show-error --max-time 60 --output /dev/null --write-out '%{http_code}\n' "$URL/info" || true
sleep 240
done
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.

@pietroid pietroid Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We have no plan for hosted web builds, so I think it is safe. Also the password should only be given at the workshop, notice we have no password defined in the repository.


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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed!

/// `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/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const runtime = new CopilotRuntime({
});

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
35 changes: 34 additions & 1 deletion 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 @@ -187,6 +190,34 @@ 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 --dart-define=RUNTIME_URL=<url> --dart-define=WORKSHOP_CODE=<password>
```

`--dart-define` compiles the password into the build, so **a build made with
`WORKSHOP_CODE` contains the password**: anyone who has that web build can read
it. Run it locally, and never host a build made with the code.

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
`ANTHROPIC_API_KEY` on the host. The runtime then answers `401` without the
password. `GET /info` and the CORS preflight stay open.

The runtime also logs one line per run with the session id, and answers `429`
once a session passes `MAX_RUNS_PER_SESSION` runs (default 100) or all sessions
pass `MAX_RUNS_TOTAL` (default 5000). Change `WORKSHOP_CODE` after the event,
and set a spend limit on the Anthropic key: `--max-instances=1` limits
concurrency, not cost.

A hosted backend that has been idle takes a few seconds to wake up, so the
first check or message can be slow. To warm it before the event, run the
`warm_runtime` workflow from the Actions tab.

## How the code is organized

Each step is a Dart workspace of small packages:
Expand Down Expand Up @@ -225,6 +256,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
7 changes: 7 additions & 0 deletions runtime/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# Copy this file to .env (git-ignored) and fill in your key.
COPILOTKIT_MODEL=anthropic:claude-sonnet-5-5
ANTHROPIC_API_KEY=

# Only for a shared, hosted runtime: the password the participants send. Leave it
# out on your machine.
# WORKSHOP_CODE=
# Caps on the runs the shared runtime accepts (the defaults):
# MAX_RUNS_PER_SESSION=100
# MAX_RUNS_TOTAL=5000
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
Loading
Loading