Skip to content
Draft
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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ Not ready to self-host? [**floom.dev**](https://floom.dev) is the hosted version
npx -y @floomhq/floom mcp install --target claude
```

For repeated agent or automation calls, [install the CLI globally once](apps/mcp/README.md#quickstart--hand-it-to-your-agent) instead of invoking it through `npx` each time.

The `.env`, model-provider, and E2B keys above are for running your own local or
self-hosted runtime, not for using Floom Cloud.

Expand Down
12 changes: 12 additions & 0 deletions apps/mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## Unreleased

### Added

- Added a `composio-proxy` Gmail worker template and documented the run-scoped Composio proxy contract in JSON and human-readable `workers contract` output.
- Added an init-to-validate regression test covering every golden worker template.
- Added guidance for globally installing the CLI for repeated automation calls, plus a soft `doctor` hint when running through `npx`.

### Fixed

- Added three validated `use_cases` to every golden template so `floom init` consistently models the worker contract.

## 5.1.11 (2026-07-06)

### Agent-first onboarding
Expand Down
2 changes: 2 additions & 0 deletions apps/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ or `--target generic` for other clients.

For CLI use outside an MCP client:

> **For agents and automation:** Install once with `npm i -g @floomhq/floom@latest`, then invoke `floom <command>` directly for repeated or scripted calls. Running `npx -y @floomhq/floom@latest <command>` each time re-resolves the package and adds avoidable cold-start latency.

```bash
npm i -g @floomhq/floom@latest
floom login
Expand Down
16 changes: 16 additions & 0 deletions apps/mcp/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,19 @@ function checkMcpInstall(): Check {
return warn("mcp_install", "Not found in any editor config", `Install: ${getCommandName()} mcp install`);
}

function checkNpxInvocation(): Check | undefined {
const scriptPath = process.argv[1] || "";
const execPath = process.env.npm_execpath || "";
const lifecycleEvent = process.env.npm_lifecycle_event || "";
const runningViaNpx = scriptPath.includes("_npx") || /(?:^|[/\\])npx(?:\.cmd)?$/i.test(execPath) || lifecycleEvent === "npx";
if (!runningViaNpx) return undefined;
return warn(
"invocation",
"Running from the npx cache",
"For repeated use, run: npm i -g @floomhq/floom@latest, then invoke floom directly",
);
}

async function checkRecentRuns(client: FloomApiClient): Promise<Check> {
try {
await client.requestJson("GET", "/runs", { query: { limit: 1 } });
Expand Down Expand Up @@ -145,6 +158,9 @@ export async function doctorCommand(options: { json?: boolean } = {}): Promise<n

const checks: Check[] = [];

const invocationCheck = checkNpxInvocation();
if (invocationCheck) checks.push(invocationCheck);

// Check 1: API reachable
checks.push(await checkApiReachable(apiBase));

Expand Down
8 changes: 8 additions & 0 deletions apps/mcp/src/commands/workers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,14 @@ export async function workersContractCommand(options: { json?: boolean }): Promi
log.kv("Required files", WORKER_AUTHORING_CONTRACT.required_files.join(", "));
log.kv("Required fields", WORKER_AUTHORING_CONTRACT.required_top_level_fields.join(", "));
log.blank();
log.info("Run-proxy (Composio/Gmail) contract:");
log.info(` Environment: ${Object.keys(WORKER_AUTHORING_CONTRACT.run_proxy.environment).join(", ")} (injected by the Floom runner)`);
log.info(` Connections: ${WORKER_AUTHORING_CONTRACT.run_proxy.connections_file.path} shaped as ${WORKER_AUTHORING_CONTRACT.run_proxy.connections_file.shape}`);
log.info(` Request: ${WORKER_AUTHORING_CONTRACT.run_proxy.request.method} ${WORKER_AUTHORING_CONTRACT.run_proxy.request.url}`);
log.info(` Header: ${WORKER_AUTHORING_CONTRACT.run_proxy.request.header}`);
log.info(` Body: ${WORKER_AUTHORING_CONTRACT.run_proxy.request.body}`);
log.info(` ${WORKER_AUTHORING_CONTRACT.run_proxy.template}`);
log.blank();
log.info("Recommended flow:");
for (const step of WORKER_AUTHORING_CONTRACT.validation_order) {
log.info(` - ${step}`);
Expand Down
178 changes: 178 additions & 0 deletions apps/mcp/src/lib/worker-authoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type WorkerDraftValidationResult = {
type JsonObject = Record<string, unknown>;

const SUPPORTED_RUNTIMES = new Set(["python311", "node22", "typescript", "ts", "bash", "skill", "none"]);
const RUNNER_INJECTED_ENV = new Set(["WORKEROS_API_URL", "FLOOM_RUN_ID", "WORKEROS_RUN_TOKEN"]);

export const WORKER_AUTHORING_CONTRACT = {
schema_version: "0.3",
Expand Down Expand Up @@ -76,6 +77,25 @@ export const WORKER_AUTHORING_CONTRACT = {
"Use allowed_tools when the worker should only call specific Composio tools.",
"Mirror connection names under capabilities.connections when the YAML includes a capabilities block.",
],
run_proxy: {
environment: {
WORKEROS_API_URL: "Base URL of the Floom API handling the active run.",
FLOOM_RUN_ID: "ID of the active Floom run.",
WORKEROS_RUN_TOKEN: "Run-scoped token sent as the X-Floom-Run-Token header.",
},
connections_file: {
path: "connections.json in the worker root",
shape: '{"gmail":"<connected_account_id>"}',
source: "Injected automatically by the Floom runner from the run's active connections.",
},
request: {
method: "POST",
url: "${WORKEROS_API_URL}/runs/${FLOOM_RUN_ID}/composio-execute/${slug}",
header: "X-Floom-Run-Token: ${WORKEROS_RUN_TOKEN}",
body: '{"connected_account_id":"<connection_id>","arguments":{...}}',
},
template: "See the composio-proxy template: floom workers templates get composio-proxy",
},
validation_order: [
"Start from workers.templates.get.",
"Fill in worker.yml plus run.py, run.ts, or SKILL.md.",
Expand All @@ -95,6 +115,10 @@ export const WORKER_TEMPLATES: WorkerTemplate[] = [
name: text-normalizer
title: Text Normalizer
description: Normalize a text input and return the normalized value.
use_cases:
- Normalize whitespace before storing user-provided text.
- Clean text fields before sending them to another system.
- Produce consistent text for deterministic comparisons.
version: "0.1.0"
entrypoint: run.py
trigger:
Expand Down Expand Up @@ -157,6 +181,10 @@ if __name__ == "__main__":
name: gmail-summary-agent
title: Gmail Summary Agent
description: Summarize recent Gmail messages into a concise markdown brief.
use_cases:
- Create a morning brief from recent email.
- Surface important messages that need attention.
- Summarize a Gmail search into a shareable report.
version: "0.1.0"
entrypoint: SKILL.md
trigger:
Expand Down Expand Up @@ -216,6 +244,10 @@ Call the Gmail runtime tool once with the declared allowed tool, summarize the r
name: approval-script
title: Approval Script
description: Build an approval payload for a proposed outbound action.
use_cases:
- Review an outbound message before it is sent.
- Approve a proposed external system update.
- Present a side-effecting action for human sign-off.
version: "0.1.0"
entrypoint: run.py
trigger:
Expand Down Expand Up @@ -276,6 +308,151 @@ if __name__ == "__main__":
"Do not require secrets during the approval-plan phase unless the plan truly needs private API data.",
],
},
{
id: "composio-proxy",
title: "Composio Run-Proxy Worker (Gmail)",
description: "Pure-script worker that calls Gmail through Floom's run-scoped Composio proxy.",
mode: "pure-script",
worker_yml: `schema_version: "0.3"
name: gmail-proxy-summary
title: Gmail Proxy Summary
description: Fetch recent Gmail messages through the Floom run proxy and write a markdown summary.
use_cases:
- Summarize recent messages from a connected Gmail inbox.
- Verify a Gmail connection with a safe read-only request.
- Build a markdown brief from a Gmail search.
version: "0.1.0"
entrypoint: run.py
trigger:
type: manual
connections:
- app: gmail
allowed_tools:
- GMAIL_FETCH_EMAILS
exec:
mode: pure-script
entry: run.py
runtime: python311
runner: e2b
command: python run.py
inputs:
- name: query
label: Gmail query
type: string
kind: scalar
required: false
default: newer_than:1d
outputs:
- name: summary
label: Gmail summary
kind: file
media_type: text/markdown
path: out/summary.md
required: true
capabilities:
network:
egress: true
secrets: []
connections:
- gmail
`,
run_py: `import json
import os
from pathlib import Path
from urllib import request as urlrequest
from urllib.error import HTTPError, URLError


WORKEROS_API_URL = os.environ.get("WORKEROS_API_URL", "https://localhost:8000").rstrip("/")
FLOOM_RUN_ID = os.environ.get("FLOOM_RUN_ID", "")
WORKEROS_RUN_TOKEN = os.environ.get("WORKEROS_RUN_TOKEN", "")


def _read_connection_id() -> str:
try:
connections = json.loads(Path("connections.json").read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError):
return ""
return str(connections.get("gmail") or "").strip()


def _write_result(status: str, *, outputs=None, artifacts=None, error=None) -> None:
result = {
"status": status,
"outputs": outputs or {},
"artifacts": artifacts or [],
"error": error,
}
Path("result.json").write_text(json.dumps(result), encoding="utf-8")


def composio_execute(slug: str, payload: dict) -> dict:
if not FLOOM_RUN_ID:
return {"successful": False, "error": "FLOOM_RUN_ID is not set"}
connection_id = _read_connection_id()
if not connection_id:
return {"successful": False, "error": "Gmail connection is not active"}

url = f"{WORKEROS_API_URL}/runs/{FLOOM_RUN_ID}/composio-execute/{slug}"
body = {"connected_account_id": connection_id, "arguments": payload}
headers = {"Content-Type": "application/json"}
if WORKEROS_RUN_TOKEN:
headers["X-Floom-Run-Token"] = WORKEROS_RUN_TOKEN
req = urlrequest.Request(
url,
data=json.dumps(body).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urlrequest.urlopen(req, timeout=120) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
return {"successful": False, "error": f"Floom proxy HTTP {exc.code}: {detail}"}
except (URLError, TimeoutError, json.JSONDecodeError) as exc:
return {"successful": False, "error": str(exc)}


def main() -> None:
try:
inputs_path = Path("inputs.json")
inputs = json.loads(inputs_path.read_text(encoding="utf-8")) if inputs_path.exists() else {}
query = str(inputs.get("query") or "newer_than:1d")
response = composio_execute("GMAIL_FETCH_EMAILS", {"query": query, "max_results": 10})
if not response.get("successful"):
raise RuntimeError(str(response.get("error") or "Gmail request failed"))

if response.get("storedInFile"):
output_path = Path(str(response.get("outputFilePath") or ""))
if not output_path.exists():
raise RuntimeError("Gmail proxy response file was not found")
response = json.loads(output_path.read_text(encoding="utf-8"))
messages = response.get("data", {}).get("messages", [])
lines = ["# Gmail summary", "", f"Found {len(messages)} message(s) for \`{query}\`."]
for message in messages[:10]:
subject = message.get("subject") or "(no subject)"
sender = message.get("sender") or "Unknown sender"
lines.append(f"- **{subject}** from {sender}")

summary_path = Path("out/summary.md")
summary_path.parent.mkdir(parents=True, exist_ok=True)
summary_path.write_text("\\n".join(lines) + "\\n", encoding="utf-8")
artifact = {"name": "summary.md", "path": str(summary_path), "type": "text/markdown"}
_write_result("success", outputs={"summary": str(summary_path)}, artifacts=[artifact], error=None)
except Exception as exc:
_write_result("error", outputs={}, artifacts=[], error=str(exc))


if __name__ == "__main__":
main()
`,
notes: [
"Use this template for script workers that call Composio tools through a Floom run.",
"The runner injects connections.json and WORKEROS_API_URL, FLOOM_RUN_ID, and WORKEROS_RUN_TOKEN automatically; worker authors never set them.",
"Keep allowed_tools restricted to the exact Composio slugs the worker calls.",
],
},
];

export function listWorkerTemplates(): Array<Omit<WorkerTemplate, "worker_yml" | "run_py" | "run_ts" | "skill_md">> {
Expand Down Expand Up @@ -454,6 +631,7 @@ export function validateWorkerDraft(input: WorkerDraftValidationInput): WorkerDr

const capSecrets = collectCapabilityList(manifest, "secrets");
for (const key of envReads) {
if (RUNNER_INJECTED_ENV.has(key)) continue;
if (!capSecrets.includes(key)) {
errors.push(`${scriptLabel} reads env ${key}, but worker.yml does not declare it under capabilities.secrets`);
}
Expand Down
44 changes: 44 additions & 0 deletions apps/mcp/test/init-validate-roundtrip.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";

async function runCli(args) {
const child = spawn(process.execPath, ["dist/cli.js", ...args], {
cwd: process.cwd(),
env: { ...process.env, FLOOM_CLI_TELEMETRY_DISABLED: "1" },
stdio: ["ignore", "pipe", "pipe"],
});
const stdout = [];
const stderr = [];
child.stdout.on("data", (chunk) => stdout.push(chunk));
child.stderr.on("data", (chunk) => stderr.push(chunk));
const [code] = await once(child, "exit");
return {
code,
stdout: Buffer.concat(stdout).toString("utf8"),
stderr: Buffer.concat(stderr).toString("utf8"),
};
}

test("every worker template initializes to a valid worker", async () => {
const listed = await runCli(["workers", "templates", "list", "--json"]);
assert.equal(listed.code, 0, listed.stderr || listed.stdout);
const templates = JSON.parse(listed.stdout).templates;
assert.ok(templates.length > 0, "listWorkerTemplates() returned no templates");

for (const template of templates) {
const dir = await mkdtemp(join(tmpdir(), `floom-init-${template.id}-`));
const initialized = await runCli(["init", dir, "--template", template.id, "--json"]);
assert.equal(initialized.code, 0, `${template.id} init failed: ${initialized.stderr || initialized.stdout}`);

const validated = await runCli(["workers", "validate", dir, "--json"]);
assert.equal(validated.code, 0, `${template.id} validate failed: ${validated.stderr || validated.stdout}`);
const result = JSON.parse(validated.stdout);
assert.equal(result.valid, true, `${template.id} validation errors: ${JSON.stringify(result.errors)}`);
assert.deepEqual(result.errors, [], `${template.id} returned validation errors`);
}
});
Loading