Skip to content
Merged
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
3 changes: 3 additions & 0 deletions sdks/flue/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
47 changes: 47 additions & 0 deletions sdks/flue/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# @opencomputer/flue

Make a stock [Flue](https://flue.dev) agent OpenComputer-native. A Flue app built with
`flue build --target cloudflare` runs unchanged as an OpenComputer durable session (a Workers-for-Platforms
tenant script); this package supplies the OC-specific wiring the app opts into.

## What it gives you

- **`useOcGateway(ctx)` + `DEFAULT_MODEL`** — point the managed `anthropic` provider at the OC model
gateway (org key injected + per-session metering). Call it **inside** your `defineAgent` initializer.
- **`route`** — the HTTP-transport opt-in every OC-hosted agent must export (`export { route }`).
- **`ocSandbox(env)`** — a durable OpenComputer-fleet sandbox as the agent's `SandboxApi` (workspace
survives across turns; also serves the repo plane).
- **`ocRepoTools(env)`** — `publish_pull_request` and friends (open PRs as the OpenComputer GitHub App).
- **`@opencomputer/flue/app`** — a default hosting app (`flue()` routes + `/health` + telemetry). Or
`import '@opencomputer/flue/wire'` from your own `app.ts` for telemetry only.

## Minimal agent

```ts
import { defineAgent, defineAgentProfile } from '@flue/runtime';
import { useOcGateway, route, ocSandbox, DEFAULT_MODEL, type OcSandboxEnv } from '@opencomputer/flue';

export { route };

export default defineAgent<OcSandboxEnv>((ctx) => {
useOcGateway(ctx);
return {
profile: defineAgentProfile({ instructions: 'You help customers.' }),
model: DEFAULT_MODEL, // prompt-caching-safe
sandbox: ocSandbox(ctx.env),
};
});
```

`src/app.ts`:

```ts
export { default } from '@opencomputer/flue/app';
```

Then `flue build --target cloudflare` and `oc agent deploy`. See `oc-flue-starter` for a full example.

## Environment (set on the tenant script by the OC deploy)

`OC_GATEWAY`, `OC_SESSION_TOKEN`, `OC_INGEST`, `OC_SANDBOX_API` (+ `OC_SANDBOX_ID` or a resolve seam),
`OC_REPO_API`. Reserved `OC_`/`FLUE_` prefixes are OC-managed.
48 changes: 48 additions & 0 deletions sdks/flue/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"name": "@opencomputer/flue",
"version": "0.2.0",
"description": "Make a stock Flue agent OpenComputer-native — OC model gateway, OC-fleet sandbox, publish/repo tools, and the default hosting app.",
"license": "MIT",
"type": "module",
"engines": {
"node": ">=22.19"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./app": {
"types": "./dist/app.d.ts",
"default": "./dist/app.js"
},
"./wire": {
"types": "./dist/wire.d.ts",
"default": "./dist/wire.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"sideEffects": [
"./dist/wire.js"
],
"peerDependencies": {
"@flue/runtime": "1.0.0-beta.9"
},
"dependencies": {
"hono": "^4.6.0",
"valibot": "^1.1.0"
},
"devDependencies": {
"@flue/runtime": "1.0.0-beta.9",
"@types/node": "^22.10.0",
"typescript": "^5.9.3",
"vitest": "^3.0.0"
}
}
30 changes: 30 additions & 0 deletions sdks/flue/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// The default OC hosting app. A scaffolded starter uses this as its `src/app.ts`. It composes the
// SAME app Flue's Cloudflare build generates for the no-`app.ts` case, then adds the `/health` probe
// the OC deploy/activate step needs (stock Flue exposes NO health route — Spike B finding) and installs
// the telemetry forwarder. Apps that own their `app.ts` instead `import '@opencomputer/flue/wire'` (§wire).
//
// WHY `createDefaultFlueApp()` from `@flue/runtime/internal` (and NOT `flue()` from
// `@flue/runtime/routing`): `flue()`'s route handlers read the module-scoped `runtimeConfig` at REQUEST
// time, which the generated Cloudflare entry sets via `configureFlueRuntime(...)` (imported from
// `@flue/runtime/internal`) at module load. The generated entry's no-`app.ts` path builds its app with
// `createDefaultFlueApp()` — the exact same `@flue/runtime/internal` entry — so the mounted `flue()` and
// the `configureFlueRuntime()` that seeds it share one module instance and requests never hit
// "flue() route invoked before runtime was configured". A `src/app.ts` that instead mounted `flue()`
// from `@flue/runtime/routing` (a different published entry) risked resolving a second `@flue/runtime`
// module instance whose `runtimeConfig` is never configured → every request 500s. Composing via the
// build's own entry keeps this app on the configured instance.
//
// Default export = a `Fetchable` (Hono qualifies), per Flue's routing contract.

import { createDefaultFlueApp } from "@flue/runtime/internal";
import { installOcObserver } from "./observe.js";

installOcObserver();

// createDefaultFlueApp() mounts flue() at '/' and installs Flue's canonical notFound/onError envelopes.
// Adding a path-specific '/health' route afterwards is safe — flue() only registers its own concrete
// paths (/agents, /workflows, /runs, /channels), so GET /health matches this handler directly.
const app = createDefaultFlueApp();
app.get("/health", (c) => c.json({ status: "ok" }));

export default app;
33 changes: 33 additions & 0 deletions sdks/flue/src/cf-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Ambient Cloudflare-Workers env access (design 013 §4). On the `flue build --target cloudflare`
// build the real Worker bindings (`OC_GATEWAY`, `OC_SESSION_TOKEN`, `OC_SANDBOX_*`, `OC_INGEST`, …)
// live on the AMBIENT env exported by `cloudflare:workers` — the same one Flue's generated entry reads
// (`import { env } from 'cloudflare:workers'`). The per-agent `ctx.env` Flue threads into the
// initializer is EMPTY for these bindings, so OC helpers must read the ambient env instead.
//
// `cloudflare:workers` only resolves inside workerd, so importing it statically would break local
// `flue dev` on the node target and the package's own vitest. Load it lazily + guarded: on CF the
// dynamic import resolves and `ambientEnv` is populated during module graph evaluation (before any
// request); everywhere else the import rejects, is caught, and callers fall back to the passed env.

let ambientEnv: Record<string, unknown> | undefined;
try {
// `@vite-ignore` so consumer/test bundlers don't try to statically resolve the workerd built-in;
// on CF this is a runtime import of the ambient module, off CF it throws and we fall back.
const mod = (await import(/* @vite-ignore */ "cloudflare:workers")) as {
env?: Record<string, unknown>;
};
ambientEnv = mod.env;
} catch {
ambientEnv = undefined;
}

/**
* Resolve the effective OC env: the Cloudflare ambient bindings layered over `fallback` (ambient wins).
* On CF this returns the real Worker bindings even though `ctx.env` is empty; off CF (local dev / node /
* tests) it returns `fallback` unchanged so an explicitly-passed env still works.
*/
export function ocResolveEnv<T extends Record<string, unknown>>(fallback: T | undefined): T {
const base = (fallback ?? {}) as T;
if (!ambientEnv) return base;
return { ...base, ...ambientEnv } as T;
}
6 changes: 6 additions & 0 deletions sdks/flue/src/cloudflare-workers.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Minimal ambient type for the workerd-only `cloudflare:workers` virtual module, so the guarded
// dynamic import in `cf-env.ts` typechecks without pulling in `@cloudflare/workers-types`. The real
// module (present only on the `--target cloudflare` build) exports the ambient Worker `env` bindings.
declare module "cloudflare:workers" {
export const env: Record<string, unknown>;
}
59 changes: 59 additions & 0 deletions sdks/flue/src/gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// OC model-gateway wiring (design 013 §4). A stock Flue app points its managed `anthropic` provider at
// the OC gateway (a thin Worker over OpenRouter that injects the org key + meters per session).
//
// TOKEN SEAM — OPEN, confirm with the orchestrator. The buildout's seam (a) ("per-turn token → Flue's
// per-call getApiKey(providerId) callback") is NOT achievable: `registerProvider` accepts only a STATIC
// `apiKey`, and Flue's internal `getApiKey(providerId)` gets no request/turn context (providers.ts:199).
// Worse, the provider registry is MODULE(isolate)-scoped and shared across co-located DO instances, so a
// per-SESSION `apiKey`/`headers`/`baseUrl` set via registerProvider RACES across sessions in one isolate.
// → Robust per-session attribution needs the upstream ask (per-request `headers(ctx)` on registerProvider,
// buildout "Upstream asks"). Interim, buildable shape below: a static env token (works one-session-per-
// isolate) with metering by `token.sub` at the gateway. See the W4 hand-off note.

import { registerProvider } from "@flue/runtime";
import type { AgentInitializerContext, AgentRouteHandler } from "@flue/runtime";
import { ocResolveEnv } from "./cf-env.js";

/** Default managed model — MUST be prompt-caching-safe (Constraint): `claude-3-haiku` fails via
* OpenRouter→Bedrock; `claude-haiku-4-5` works. Use the pi-ai CATALOG id with DASHES
* (`claude-haiku-4-5`), never a dot (`claude-haiku-4.5`): the dotted id is absent from pi-ai's model
* catalog, so pi-ai can't derive the model's max output tokens and defaults `max_tokens` to 1 → empty
* completions. The dashed id resolves in the catalog and OpenRouter routes it too. Cheap + caching-safe. */
export const DEFAULT_MODEL = "anthropic/claude-haiku-4-5";

export interface OcEnv {
/** Deployed gateway Worker base URL (set per tenant script by the OC deploy). */
OC_GATEWAY?: string;
/** Signed session/deploy JWT the gateway verifies (`{sub, org, agt, bud}`); never a raw provider key. */
OC_SESSION_TOKEN?: string;
/** Telemetry sink for `observe()` (operator panel + spend attribution). */
OC_INGEST?: string;
[key: string]: unknown;
}

/**
* Point the managed `anthropic` provider at the OC gateway. **Call this INSIDE the `defineAgent`
* initializer** — top-level module code is stripped by the CF build (proven in 1a), and the initializer
* body runs per harness init. Reads the CF ambient env (`cloudflare:workers`), not `ctx.env`: on the
* `--target cloudflare` build the real Worker bindings live on the ambient env and `ctx.env` is empty
* for them (Flue's generated entry threads `instance.env`, which lacks the OC bindings), so reading
* `ctx.env` alone would leave `OC_GATEWAY` unset and the provider unregistered ("Unknown model
* specifier"). No-op when `OC_GATEWAY` is unset (local `flue dev` falls through to pi-ai's env-var key
* lookup). `anthropic` is a catalog id, so `baseUrl` alone rehydrates the wire protocol.
*/
export function useOcGateway(ctx: AgentInitializerContext<OcEnv>): void {
const env = ocResolveEnv<OcEnv>(ctx.env);
const gw = env.OC_GATEWAY;
if (!gw) return;
registerProvider("anthropic", {
baseUrl: `${gw.replace(/\/+$/, "")}/anthropic`,
...(env.OC_SESSION_TOKEN ? { apiKey: env.OC_SESSION_TOKEN } : {}),
});
}

/**
* The HTTP-transport opt-in every OC-hosted agent MUST export as `route` (an agent is reachable at
* `/agents/:name/:id` only when its module exports `route` — flue-app.ts). Pass-through: the OC dispatch
* Worker is the auth boundary (013 §3 B5), so the app adds none.
*/
export const route: AgentRouteHandler = async (_c, next) => next();
15 changes: 15 additions & 0 deletions sdks/flue/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// @opencomputer/flue — make a stock Flue agent OpenComputer-native (design 013 §4/§5).
// - useOcGateway + route + DEFAULT_MODEL: point managed anthropic at the OC gateway; HTTP-transport opt-in.
// - ocSandbox: a durable OC-fleet sandbox as the agent's SandboxApi.
// - ocRepoTools: publish/repo tools (repo plane).
// - installOcObserver: forward lifecycle/usage to OC_INGEST.
// Default hosting app is at `@opencomputer/flue/app`; `@opencomputer/flue/wire` is the telemetry-only
// side-effect for apps with their own app.ts.

export { useOcGateway, route, DEFAULT_MODEL } from "./gateway.js";
export type { OcEnv } from "./gateway.js";
export { ocSandbox, WORKSPACE_CWD } from "./sandbox.js";
export type { OcSandboxEnv } from "./sandbox.js";
export { installOcObserver } from "./observe.js";
export { ocRepoTools } from "./tools.js";
export type { OcRepoEnv } from "./tools.js";
27 changes: 27 additions & 0 deletions sdks/flue/src/observe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Telemetry: forward Flue lifecycle/usage observations to OC_INGEST for the operator panel + spend
// attribution (design 013 §4; buildout Integration seams). The DO transcript stays authoritative — the
// TAILER is the event-truth path; `observe()` is a best-effort side channel, so this is fire-and-forget
// and never blocks or breaks a run. `observe` subscribers receive `ctx.env`, so this reads OC_INGEST per
// event and can be installed once at module load (isolate-scoped, matching observe's own scope).

import { observe } from "@flue/runtime";

interface CtxEnv { OC_INGEST?: string; OC_SESSION_TOKEN?: string }

/** Install the OC observation forwarder. Returns the unsubscribe fn. No-op per event when OC_INGEST unset. */
export function installOcObserver(): () => void {
return observe((obs, ctx) => {
try {
const env = (ctx as { env?: CtxEnv }).env;
if (!env?.OC_INGEST) return;
const session = (obs as { session?: string }).session ?? (ctx as { id?: string }).id;
void fetch(env.OC_INGEST, {
method: "POST",
headers: { "content-type": "application/json", ...(env.OC_SESSION_TOKEN ? { authorization: `Bearer ${env.OC_SESSION_TOKEN}` } : {}) },
body: JSON.stringify({ session, agent: (ctx as { agentName?: string }).agentName, event: obs }),
}).catch(() => {});
} catch {
/* telemetry must never break the run */
}
});
}
Loading