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
2 changes: 1 addition & 1 deletion .github/workflows/public-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
- run: npm ci
- run: npm audit --omit=dev --audit-level=high
- name: Focused tests, leaving nothing in the temp location
run: node scripts/test-temp-guard.mjs test/cloud-lite.test.mjs test/cloud-lite-claude.test.mjs test/cloud-lite-provider.test.mjs test/model-provider.test.mjs test/selfhost-modal.test.mjs test/public-sdk.test.mjs test/template-catalog.test.mjs test/factory-team-template.test.mjs test/flujo-workspace.test.mjs test/savia-case-plan.test.mjs test/savia-topology.test.mjs test/managed-fleet.test.mjs test/managed-fleet-real-module.test.mjs test/managed-fleet-provider-closure.test.mjs test/provider-cell-retirement.test.mjs test/presentation.test.mjs test/presentation-modal.test.mjs test/presentation-closure.test.mjs test/observatory-client.test.mjs test/workflows.test.mjs test/workflow-native-wake.test.mjs test/workflow-m0-lifecycle.test.mjs test/workflow-m1-portable-artifacts.test.mjs test/workflow-m2-ack-recovery.test.mjs test/workflow-m2-authority-recovery.test.mjs
run: node scripts/test-temp-guard.mjs test/cloud-lite.test.mjs test/cloud-lite-claude.test.mjs test/cloud-lite-flujo.test.mjs test/cloud-lite-provider.test.mjs test/model-provider.test.mjs test/selfhost-modal.test.mjs test/public-sdk.test.mjs test/template-catalog.test.mjs test/factory-team-template.test.mjs test/flujo-workspace.test.mjs test/savia-case-plan.test.mjs test/savia-topology.test.mjs test/managed-fleet.test.mjs test/managed-fleet-real-module.test.mjs test/managed-fleet-provider-closure.test.mjs test/provider-cell-retirement.test.mjs test/presentation.test.mjs test/presentation-modal.test.mjs test/presentation-closure.test.mjs test/observatory-client.test.mjs test/workflows.test.mjs test/workflow-native-wake.test.mjs test/workflow-m0-lifecycle.test.mjs test/workflow-m1-portable-artifacts.test.mjs test/workflow-m2-ack-recovery.test.mjs test/workflow-m2-authority-recovery.test.mjs
- name: Self-hosted model settings (Python, offline)
run: python3 -B -m unittest discover -s selfhost/modal -p "test_*.py"
- name: Install and exercise the packed public package
Expand Down
100 changes: 91 additions & 9 deletions PUBLIC_SDK.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# Public local Factory SDK, CLI, and MCP

Version 0.6.0 is distributed as a versioned package in
[GitHub Releases](https://github.com/flujo-app/FACTORY/releases/tag/v0.6.0):
Version 0.7.0 is distributed as a versioned package in
[GitHub Releases](https://github.com/flujo-app/FACTORY/releases/tag/v0.7.0):

```sh
npm install https://github.com/flujo-app/FACTORY/releases/download/v0.6.0/flujo-factory-0.6.0.tgz
npm install https://github.com/flujo-app/FACTORY/releases/download/v0.7.0/flujo-factory-0.7.0.tgz
```

FACTORY can serve an open-weight model on your own Modal GPUs and run workers on it.
`ModalSelfHostedModel` and `factory-selfhost` deploy, verify, stop and retire a vLLM endpoint
with Qwen3.8-27B (H100) or Nemotron 3.5 Super (B300) profiles; see
[selfhost/README.md](selfhost/README.md). Its endpoint description is a model provider that
cloud and local lite workers (Codex or Claude Code) use directly. See
cloud and local lite workers (Codex, Claude Code or FLUJO) use directly. See
[Self-hosted models and operator-driven jobs](#self-hosted-models-and-operator-driven-jobs)
for the provider, read-only deploy keys, prepare hooks, operator-driven jobs and quieter
traces added in 0.4.0. All of it is optional; existing plans behave as before, except that
Expand Down Expand Up @@ -137,7 +137,7 @@ local overrides are not implicitly shared with a remote adapter.
### Lite workers

`factory-lite` opens a regular interactive Codex or Claude CLI session in the
current directory. It does not provision a FLUJO worker or use the FLUJO
current directory, or runs a FLUJO worker (see below). A CLI session does not provision a FLUJO worker or use the FLUJO
conversation harness. The CLI retains its built-in tools and unrestricted file
and command access. The only MCP server supplied for that session is FACTORY's
local board/swarm server, connected to the specified durable database.
Expand Down Expand Up @@ -201,6 +201,23 @@ description the cloud plan uses (see
command line, `factory-lite claude board.sqlite --model-provider endpoint.json`
reads the key from the variable the file names in `envKey`.

A local FLUJO worker has no terminal session. It attaches to a running FLUJO, or starts
its own with a separate data folder, installs the generic team template with the board
as a tool server, and keeps its conversation across runs:

```js
const worker = await factory.startLiteWorker({ provider: 'flujo', modelProvider, providerKey,
origin: 'http://127.0.0.1:4200', prompt: 'Work on the Factory board' });
const turn = await worker.settled();
await worker.send('Next job');
await worker.stop();
```

From the command line: `factory-lite flujo board.sqlite --model-provider endpoint.json
[--origin http://127.0.0.1:4200] "Work on the Factory board"`. Without `--origin`, the
`flujo` command from `npm install -g flujo-ai` must be on the path. A record from
`POST /api/model` can stand in for a provider as `flujoModel`.

## FLUJO worker dispatch

### Cloud lite workers (development)
Expand Down Expand Up @@ -320,6 +337,71 @@ await selfHostedFleet.deploy({ ...plan, workerProgram: 'claude', model: provider
`thread/tokenUsage/updated`, and model retries become `model/retry`. Streaming deltas
are skipped unless `traceDeltas` is on.

#### FLUJO as the worker program

Set `workerProgram: 'flujo'` to run a FLUJO server on each machine. The board, operator
commands and stop receipts, durable traces, prepare hooks, evidence collection and
verified workflows are the same shared code as for Codex and Claude Code.

```js
const fleet = new FactoryCloudLiteFleet({ directory: '/absolute/private/fleet-run',
flujoCloudPath: '/absolute/installed/flujo-cloud' });
await fleet.deploy({ runId: 'my-flujo-run', app: 'my-flujo-app', goal: ownerGoal,
workerProgram: 'flujo', machines: 3, agentsPerMachine: 1,
modelProvider: model.endpoint(),
// Optional: each orchestrator leads a team of native FLUJO subflows.
flujo: { teamConcurrency: 4, agentTurns: 200, leadTurns: 600 },
}, { image: immutableNodeImage, bundlePath: '/absolute/private/flujo-factory.tgz', providerKey: await model.key() });
```

- **Model:** a plan needs a `modelProvider`; FLUJO workers have no subscription login. It
can offer `responses`, `chat` or `anthropic`, and FLUJO uses the first of those that the
provider has. `flujoProviderModel` is the exported mapping. FLUJO stores the key in its
own encrypted settings, so the FLUJO process and the agent commands it starts never have
it in their environment. `reasoningEffort` does not apply: FLUJO accepts it only for
models it knows by name.
- **Bundle:** `bundlePath` can be a release file, or what `packFactorySdk(file)` writes from
the installed package, which needs no `npm` or `tar` on the host. `network` in the plan
puts the fleet's app on its own private network.
- **Install:** the bootstrap installs one pinned `flujo-ai` release on the volume, checked
against its SHA-256, so a restarted Machine does not install it again. Allow about four
minutes on first start. A custom image can provide `flujo` on the path instead. Use a Node image of
at least 24.22.1 or 22.23.4, running as root; FLUJO's official image runs as a normal user.
Fly needs the full reference, such as `docker.io/library/node@sha256:...`.
- **Sessions:** each agent slot has its own FLUJO workspace with one conversation, so its
board identity, model record and tools stay apart. FLUJO runs on port 4210 on the
Machine's loopback, or `FACTORY_FLUJO_PORT`. Each workspace runs its own tool servers, so
plan on about 400 MB per agent plus 300 MB for FLUJO. FLUJO restarts by itself up to
three times in ten minutes; after that the worker stops, as with the other programs.
- **Team:** with `flujo.teamConcurrency` an orchestrator gets the `swarm_team` flow, with one
native gate for its subflows, and `flujo.specialists` can give it a role catalog. The others get `swarm_agent`. A turn ends, and a stop receipt
is issued, only after every child conversation has ended too. Each conversation lists its
children under `/v1/chat/conversations?descendantsOf=<thread>`.
- **Tools:** `bash` and `filesystem`, plus `browser` if `flujo.servers` lists it and the image
has its browser; the bootstrap does not download one. The FLUJO control server stays off.
The board server takes its address and credential from a private file. A worker's prompt
names its working folder, because FLUJO's tools otherwise start in FLUJO's data folder.
- **Sandbox:** only `danger-full-access`. FLUJO's folder settings are a guide for its tools,
not a boundary, so the Machine is the sandbox, and a plan with a narrower `sandbox` is
refused. Agents get no GitHub token, as with other self-hosted model plans. An agent command
runs as the same user, so it could still read FLUJO's stored settings, including the model
key; use another program with a narrower sandbox when agents handle untrusted input.
- **Runs:** a run belongs to FLUJO, not to the connection that asked for it. A dropped event
stream is followed again from its last event. A submission whose answer was lost is read
back and never sent twice. While FLUJO is under pressure a turn waits for up to five
minutes. A question or form from a tool is declined, because nobody is there to answer it.
- **Messages and stops:** a message during a turn joins it at its next safe point; if the run
ended before taking it, the same turn goes on with it. A stop is requested again until FLUJO
has stopped, and wins over a queued message. If it never stops, no receipt is issued.
- **Restarts:** the conversation survives. A turn that was running when FLUJO died ends as
`interrupted`. It is not replayed; in autonomous mode the agent gets a new turn. If the
conversation is gone, the usual recovery for an unavailable session applies.
- **Traces and evidence:** messages, tool calls and results and subflow starts and ends become
`item/completed` records (`agentMessage`, `toolCall`, `toolResult`, `subflow`), with `depth`
and `lane` for children. Token use becomes `thread/tokenUsage/updated`, retries become
`model/retry`, and errors become `flujo/error`. Evidence holds the conversations from
`/data/flujo/workspaces`, not its keys, server settings or tool copies.

#### Self-hosted models and operator-driven jobs

To run the model yourself, `ModalSelfHostedModel` (CLI `factory-selfhost`) deploys a
Expand Down Expand Up @@ -357,12 +439,12 @@ await fleet.command('m0-a1', { id: 'job-1', action: 'message', message: 'First j
maps to its own settings. `id`, `envKey` (the variable that carries the key) and
`https` `baseUrls` (OpenAI-style, ending in `/v1`) without credentials are
required. `apis` lists the wire APIs the server offers: `responses` (Codex),
`anthropic` (Claude Code, `/v1/messages`) and `chat`. vLLM serves all three; the
default is `['responses']`. `name`, `model`, `contextWindow`,
`anthropic` (Claude Code, `/v1/messages`) and `chat`; FLUJO can use any of them. vLLM
serves all three; the default is `['responses']`. `name`, `model`, `contextWindow`,
`autoCompactTokenLimit`, `toolOutputTokenLimit`, `requestMaxRetries`,
`streamMaxRetries`, `streamIdleTimeoutMs` and `requestTimeoutMs` are optional.
`codexProviderConfig`, `codexProviderOverrides` and `claudeProviderEnvironment`
are exported mappings; `providerPrograms(provider)` says which programs can use
`codexProviderConfig`, `codexProviderOverrides`, `claudeProviderEnvironment` and
`flujoProviderModel` are exported mappings; `providerPrograms(provider)` says which programs can use
it. Each Codex gets a `config.toml` with the provider, the model's real context
window and its compaction point; web search, multi-agent, apps, plugins, goals,
memories and skill instructions are off, and agents' shell commands inherit only
Expand Down
5 changes: 4 additions & 1 deletion bin/factory-cloud-mcp.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { startCloudBoardMcp } from '../src/cloud-lite/mcp.mjs';
await startCloudBoardMcp();
// `--config <file>` carries the address and credential for hosts that keep server settings in clear view.
const flag = process.argv.indexOf('--config');
await startCloudBoardMcp(flag === -1 ? {} : JSON.parse(readFileSync(process.argv[flag + 1], 'utf8')));
29 changes: 26 additions & 3 deletions bin/factory-lite.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
import { readFileSync } from 'node:fs';
import { Factory } from '../src/public-sdk.mjs';

const usage = 'Usage: factory-lite <codex|claude> <database-path> [--model-provider <endpoint.json>] [prompt]\n'
const usage = 'Usage: factory-lite <codex|claude|flujo> <database-path> [--model-provider <endpoint.json>] [--origin <url>] [prompt]\n'
+ ' --model-provider use a self-hosted model (e.g. from `factory-selfhost endpoint`); its key is read from the\n'
+ ' environment variable the file names in envKey.\n';
+ ' environment variable the file names in envKey. FLUJO needs this.\n'
+ ' --origin FLUJO only: attach to this running FLUJO instead of starting one.\n';
const args = process.argv.slice(2);
const [provider, database] = args;
if (provider === '--help' || provider === 'help' || !provider) {
Expand All @@ -22,9 +23,31 @@ if (provider === '--help' || provider === 'help' || !provider) {
providerKey = process.env[modelProvider.envKey];
if (!providerKey) throw new Error(`Set ${modelProvider.envKey} to the endpoint key.`);
}
const child = new Factory(database).startLiteWorker({ provider, prompt: words.length ? words.join(' ') : undefined, modelProvider, providerKey });
let origin;
const originFlag = words.indexOf('--origin');
if (originFlag !== -1) {
origin = words[originFlag + 1];
if (provider !== 'flujo' || !origin) throw new Error('--origin needs a URL and is for FLUJO workers.');
words.splice(originFlag, 2);
}
const started = new Factory(database).startLiteWorker({ provider, prompt: words.length ? words.join(' ') : undefined, modelProvider, providerKey, ...(origin ? { origin } : {}) });
if (provider === 'flujo') {
const worker = await started;
process.stdout.write(`FLUJO worker ready at ${worker.origin} in workspace ${worker.workspace}.\n`);
const finish = () => { void worker.stop(); };
process.once('SIGINT', finish); process.once('SIGTERM', finish);
// With a prompt the worker ends with its turn; without one a started server stays up until stopped.
if (words.length) {
const turn = await worker.settled();
process.stdout.write(`Turn ended: ${turn?.status}.\n`);
process.exitCode = turn?.status === 'completed' ? 0 : 1;
await worker.stop();
} else if (origin) await worker.stop();
} else {
const child = started;
child.on('error', error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
child.on('exit', (code, signal) => { process.exitCode = code ?? (signal ? 1 : 0); });
}
} catch (error) {
process.stderr.write(`${error.message}\n`);
process.exitCode = 1;
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "flujo-factory",
"version": "0.6.0",
"version": "0.7.0",
"description": "Durable FLUJO swarm engine and Factory SDK, CLI, and MCP server",
"license": "MIT",
"repository": {
Expand Down
6 changes: 5 additions & 1 deletion scripts/verify-public-sdk.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@ assert.equal(providerPlan.modelProvider.autoCompactTokenLimit, 80000);
assert.throws(() => sdk.validateCloudLitePlan({ ...providerPlan, modelProvider: { ...providerPlan.modelProvider, baseUrls: ['http://model.example/v1'] } }), TypeError);
// One provider description maps to Codex and Claude Code.
const endpoint = { ...providerPlan.modelProvider, model: 'served-model', apis: ['responses', 'anthropic'] };
assert.deepEqual(sdk.providerPrograms(endpoint), ['codex', 'claude']);
assert.deepEqual(sdk.providerPrograms(endpoint), ['codex', 'claude', 'flujo']);
assert.equal(sdk.flujoProviderModel(endpoint, { key: 'package-smoke-key' }).adapter, 'openai-responses');
assert.equal(typeof sdk.FlujoSessionDriver, 'function');
assert.equal(typeof sdk.packFactorySdk, 'function');
assert.match(sdk.cloudLiteBootstrap('a'.repeat(64), { workerProgram: 'flujo' }), /flujo-ai-\d+\.\d+\.\d+\.tgz/);
assert.match(sdk.codexProviderConfig(endpoint), /wire_api = "responses"/);
assert.equal(sdk.claudeProviderEnvironment(endpoint, { key: 'package-smoke-key' }).ANTHROPIC_BASE_URL, 'https://model.example');
// Self-hosted model serving ships its Python sources and profiles, but not its tests.
Expand Down
2 changes: 1 addition & 1 deletion selfhost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ FACTORY can serve an open-weight model on your own Modal GPUs with vLLM, so work
instead of a vendor API: source code and prompts stay in your account. The served endpoint
speaks the OpenAI Responses API (Codex), the Anthropic Messages API (Claude Code) and Chat
Completions, and plugs straight into `plan.modelProvider` and the local lite workers. Cloud lite workers can
run either program on it (`plan.workerProgram`).
run any of their programs on it (`plan.workerProgram`: Codex, Claude Code or FLUJO).

This folder is separate from `modal/`, which holds the earlier pilot definitions.

Expand Down
18 changes: 18 additions & 0 deletions src/cloud-lite/bootstrap.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/** Bootstrap on the pinned upstream Node image when a custom registry is unavailable. */
const CLAUDE_CODE_VERSION = '2.1.311';
const CLAUDE_CODE_LINUX_X64_SHA256 = '86dc1fcc69498159eaf15f446613e847010f9036bfbbdffd4c49350be8489ada';
const FLUJO_VERSION = '3.48.3';
const FLUJO_SHA256 = '09e954ec5b15e30748c28576b2c0bbe3d4f5cdc34afbbbc139d92759474b6b98';
const programInstall = {
codex: {
folder: '/data/codex', packages: '', home: 'CODEX_HOME=/data/codex',
Expand All @@ -21,6 +23,22 @@ echo '${CLAUDE_CODE_LINUX_X64_SHA256} /tmp/claude' | sha256sum -c -
install -m 755 /tmp/claude /usr/local/bin/claude
rm -f /tmp/claude`,
},
// Kept on the volume, so a restarted Machine does not install it again. An image that already has
// `flujo` on its path skips this. Install scripts stay off: they only download a browser workers leave off.
flujo: {
folder: '/data/flujo /data/factory-private /data/results', packages: ' ripgrep python3-venv python3-pip', home: 'FLUJO_DATA_DIR=/data/flujo',
install: `if ! command -v flujo >/dev/null 2>&1; then
if [ ! -f /data/flujo-program/.installed-${FLUJO_VERSION} ]; then
rm -rf /data/flujo-program
curl -fsSL https://registry.npmjs.org/flujo-ai/-/flujo-ai-${FLUJO_VERSION}.tgz -o /tmp/flujo.tgz
echo '${FLUJO_SHA256} /tmp/flujo.tgz' | sha256sum -c -
npm install --prefix /data/flujo-program /tmp/flujo.tgz --ignore-scripts --omit=dev --no-audit --no-fund
rm -f /tmp/flujo.tgz
touch /data/flujo-program/.installed-${FLUJO_VERSION}
fi
ln -sf /data/flujo-program/node_modules/.bin/flujo /usr/local/bin/flujo
fi`,
},
};

export function cloudLiteBootstrap(bundleSha256, { workerProgram = 'codex' } = {}) {
Expand Down
Loading
Loading