From 43035fba94b7e1c795e2d8f84311509535e60050 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 29 Mar 2026 12:08:02 +0200 Subject: [PATCH 1/5] feat: add end-to-end SDK examples Five runnable TypeScript examples covering the core relayfile SDK: - 01: read files (listTree, readFile, queryFiles) - 02: write files (writeFile, bulkWrite, optimistic locking) - 03: webhook ingestion (ingestWebhook, computeCanonicalPath) - 04: realtime events (getEvents polling with cursors) - 05: scoped agent permissions (path-restricted tokens, 403 handling) Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/01-agent-reads-files/README.md | 44 ++++++ examples/01-agent-reads-files/index.ts | 78 ++++++++++ examples/02-agent-writes-files/README.md | 31 ++++ examples/02-agent-writes-files/index.ts | 140 ++++++++++++++++++ examples/03-webhook-to-vfs/README.md | 36 +++++ examples/03-webhook-to-vfs/index.ts | 125 ++++++++++++++++ examples/04-realtime-events/README.md | 49 +++++++ examples/04-realtime-events/index.ts | 98 +++++++++++++ examples/05-relayauth-scoped-agent/README.md | 61 ++++++++ examples/05-relayauth-scoped-agent/index.ts | 145 +++++++++++++++++++ examples/README.md | 43 ++++++ 11 files changed, 850 insertions(+) create mode 100644 examples/01-agent-reads-files/README.md create mode 100644 examples/01-agent-reads-files/index.ts create mode 100644 examples/02-agent-writes-files/README.md create mode 100644 examples/02-agent-writes-files/index.ts create mode 100644 examples/03-webhook-to-vfs/README.md create mode 100644 examples/03-webhook-to-vfs/index.ts create mode 100644 examples/04-realtime-events/README.md create mode 100644 examples/04-realtime-events/index.ts create mode 100644 examples/05-relayauth-scoped-agent/README.md create mode 100644 examples/05-relayauth-scoped-agent/index.ts create mode 100644 examples/README.md diff --git a/examples/01-agent-reads-files/README.md b/examples/01-agent-reads-files/README.md new file mode 100644 index 0000000..d5e7bb0 --- /dev/null +++ b/examples/01-agent-reads-files/README.md @@ -0,0 +1,44 @@ +# 01 — Agent reads files + +The simplest relayfile example: connect to a workspace and read its contents. + +## What it shows + +| Method | Purpose | +|--------|---------| +| `listTree` | Browse the virtual filesystem tree | +| `readFile` | Fetch a single file's content and metadata | +| `queryFiles` | Search files by provider or semantic properties | + +## Run + +```bash +export RELAYFILE_TOKEN="ey…" # JWT with fs:read scope +export WORKSPACE_ID="ws_demo" + +npx tsx index.ts +``` + +## Expected output + +``` +── listTree (depth 2) ── + 📁 /github + 📄 /github/pulls/42.json rev=r_abc123 + … + +── readFile /github/pulls/42.json ── + contentType : application/json + revision : r_abc123 + provider : github + content : {"title":"Add auth middleware",…}… + +── queryFiles (provider=github) ── + /github/pulls/42.json props={"status":"open"} + +── queryFiles (property status=open) ── + matched 1 file(s) + /github/pulls/42.json + +Done. +``` diff --git a/examples/01-agent-reads-files/index.ts b/examples/01-agent-reads-files/index.ts new file mode 100644 index 0000000..8aa6fef --- /dev/null +++ b/examples/01-agent-reads-files/index.ts @@ -0,0 +1,78 @@ +/** + * Example 01 — Agent reads files from a workspace + * + * Demonstrates the simplest relayfile flow: connect to a workspace and + * browse its virtual filesystem using listTree, readFile, and queryFiles. + * + * Run: npx tsx index.ts + * Env: RELAYFILE_TOKEN — JWT with fs:read scope + * WORKSPACE_ID — target workspace ID + */ + +import { RelayFileClient } from "@relayfile/sdk"; + +const token = process.env.RELAYFILE_TOKEN; +const workspaceId = process.env.WORKSPACE_ID ?? "ws_demo"; + +if (!token) { + console.error("Set RELAYFILE_TOKEN before running this example."); + process.exit(1); +} + +const client = new RelayFileClient({ token }); + +// ── 1. List the workspace tree ────────────────────────────────────────────── + +console.log("── listTree (depth 2) ──"); +const tree = await client.listTree(workspaceId, { depth: 2 }); + +for (const entry of tree.entries) { + const icon = entry.type === "dir" ? "📁" : "📄"; + console.log(` ${icon} ${entry.path} rev=${entry.revision}`); +} + +if (tree.nextCursor) { + console.log(` … more entries (cursor: ${tree.nextCursor})`); +} + +// ── 2. Read a single file ─────────────────────────────────────────────────── + +const targetPath = tree.entries.find((e) => e.type === "file")?.path; + +if (targetPath) { + console.log(`\n── readFile ${targetPath} ──`); + const file = await client.readFile(workspaceId, targetPath); + console.log(` contentType : ${file.contentType}`); + console.log(` revision : ${file.revision}`); + console.log(` provider : ${file.provider ?? "(agent-written)"}`); + console.log(` content : ${file.content.slice(0, 200)}…`); +} else { + console.log("\nNo files found in tree — skipping readFile."); +} + +// ── 3. Query files by provider ────────────────────────────────────────────── + +console.log("\n── queryFiles (provider=github) ──"); +const query = await client.queryFiles(workspaceId, { provider: "github" }); + +if (query.items.length === 0) { + console.log(" (no github files — try ingesting a webhook first)"); +} else { + for (const item of query.items.slice(0, 5)) { + console.log(` ${item.path} props=${JSON.stringify(item.properties)}`); + } +} + +// ── 4. Query files by semantic property ───────────────────────────────────── + +console.log("\n── queryFiles (property status=open) ──"); +const byProp = await client.queryFiles(workspaceId, { + properties: { status: "open" }, +}); + +console.log(` matched ${byProp.items.length} file(s)`); +for (const item of byProp.items.slice(0, 3)) { + console.log(` ${item.path}`); +} + +console.log("\nDone."); diff --git a/examples/02-agent-writes-files/README.md b/examples/02-agent-writes-files/README.md new file mode 100644 index 0000000..c97aa54 --- /dev/null +++ b/examples/02-agent-writes-files/README.md @@ -0,0 +1,31 @@ +# 02 — Agent writes files + +Write files into the relayfile virtual filesystem, then read them back. + +## What it shows + +| Method | Purpose | +|--------|---------| +| `writeFile` | Create or update a single file with metadata | +| `writeFile` + `baseRevision` | Optimistic concurrency via If-Match | +| `RevisionConflictError` | Handle 409 conflicts when a revision is stale | +| `bulkWrite` | Atomically write multiple files in one request | +| `readFile` | Verify written content | + +## Run + +```bash +export RELAYFILE_TOKEN="ey…" # JWT with fs:read + fs:write scopes +export WORKSPACE_ID="ws_demo" + +npx tsx index.ts +``` + +## Key concepts + +**Optimistic locking** — pass the file's current `revision` as `baseRevision` +to ensure your write only succeeds if the file hasn't changed since you read it. +Use `"*"` to create-or-overwrite without checking. + +**Bulk writes** — `bulkWrite` writes multiple files in a single request. +The response tells you how many succeeded and includes per-file errors. diff --git a/examples/02-agent-writes-files/index.ts b/examples/02-agent-writes-files/index.ts new file mode 100644 index 0000000..49fec62 --- /dev/null +++ b/examples/02-agent-writes-files/index.ts @@ -0,0 +1,140 @@ +/** + * Example 02 — Agent writes files and reads them back + * + * Demonstrates writing files into the relayfile virtual filesystem: + * single writes with writeFile, atomic multi-file writes with bulkWrite, + * and reading files back to verify. + * + * Run: npx tsx index.ts + * Env: RELAYFILE_TOKEN — JWT with fs:read + fs:write scopes + * WORKSPACE_ID — target workspace ID + */ + +import { RelayFileClient, RevisionConflictError } from "@relayfile/sdk"; + +const token = process.env.RELAYFILE_TOKEN; +const workspaceId = process.env.WORKSPACE_ID ?? "ws_demo"; + +if (!token) { + console.error("Set RELAYFILE_TOKEN before running this example."); + process.exit(1); +} + +const client = new RelayFileClient({ token }); + +// ── 1. Write a single file ────────────────────────────────────────────────── + +console.log("── writeFile /agents/summariser/config.json ──"); + +const writeResult = await client.writeFile({ + workspaceId, + path: "/agents/summariser/config.json", + baseRevision: "*", // create-or-overwrite + content: JSON.stringify( + { + name: "summariser", + model: "claude-sonnet-4-6", + maxTokens: 4096, + schedule: "on-webhook", + }, + null, + 2 + ), + contentType: "application/json", + semantics: { + properties: { owner: "platform-team", env: "staging" }, + relations: ["agent:summariser"], + }, +}); + +console.log(` opId : ${writeResult.opId}`); +console.log(` revision : ${writeResult.targetRevision}`); + +// ── 2. Read it back ───────────────────────────────────────────────────────── + +console.log("\n── readFile /agents/summariser/config.json ──"); +const file = await client.readFile( + workspaceId, + "/agents/summariser/config.json" +); +console.log(` revision : ${file.revision}`); +console.log(` content : ${file.content}`); + +// ── 3. Optimistic update (If-Match) ──────────────────────────────────────── + +console.log("\n── writeFile with If-Match (optimistic update) ──"); + +const updated = JSON.parse(file.content); +updated.maxTokens = 8192; + +const updateResult = await client.writeFile({ + workspaceId, + path: "/agents/summariser/config.json", + baseRevision: file.revision, // only succeeds if nobody else changed it + content: JSON.stringify(updated, null, 2), + contentType: "application/json", +}); + +console.log(` new revision : ${updateResult.targetRevision}`); + +// ── 4. Demonstrate conflict detection ─────────────────────────────────────── + +console.log("\n── writeFile with stale revision (expect 409) ──"); + +try { + await client.writeFile({ + workspaceId, + path: "/agents/summariser/config.json", + baseRevision: file.revision, // stale — already updated above + content: '{"stale": true}', + contentType: "application/json", + }); +} catch (err) { + if (err instanceof RevisionConflictError) { + console.log(` Caught RevisionConflictError`); + console.log(` expected : ${err.expectedRevision}`); + console.log(` current : ${err.currentRevision}`); + } else { + throw err; + } +} + +// ── 5. Bulk write multiple files atomically ───────────────────────────────── + +console.log("\n── bulkWrite 3 analysis files ──"); + +const bulkResult = await client.bulkWrite({ + workspaceId, + files: [ + { + path: "/analysis/2026-03-29/sentiment.json", + content: JSON.stringify({ score: 0.82, label: "positive" }), + contentType: "application/json", + }, + { + path: "/analysis/2026-03-29/topics.json", + content: JSON.stringify({ topics: ["auth", "performance", "ux"] }), + contentType: "application/json", + }, + { + path: "/analysis/2026-03-29/summary.md", + content: + "# Daily Summary\n\nSentiment is positive. Key topics: auth, performance, UX.", + contentType: "text/markdown", + }, + ], +}); + +console.log(` written : ${bulkResult.written}`); +console.log(` errorCount : ${bulkResult.errorCount}`); + +// ── 6. Read back a bulk-written file ──────────────────────────────────────── + +console.log("\n── readFile /analysis/2026-03-29/summary.md ──"); +const summary = await client.readFile( + workspaceId, + "/analysis/2026-03-29/summary.md" +); +console.log(` content:\n${summary.content}`); + +console.log("\nDone."); diff --git a/examples/03-webhook-to-vfs/README.md b/examples/03-webhook-to-vfs/README.md new file mode 100644 index 0000000..8a91aa4 --- /dev/null +++ b/examples/03-webhook-to-vfs/README.md @@ -0,0 +1,36 @@ +# 03 — Webhook to VFS + +Ingest external webhooks into the relayfile virtual filesystem. + +## What it shows + +| Concept | Purpose | +|---------|---------| +| `computeCanonicalPath` | Map provider objects to deterministic file paths | +| `ingestWebhook` | Push external events (GitHub, Slack, etc.) into the VFS | +| `readFile` | Read back the ingested data as a file | + +## Run + +```bash +export RELAYFILE_TOKEN="ey…" # JWT with sync:trigger + fs:read scopes +export WORKSPACE_ID="ws_demo" + +npx tsx index.ts +``` + +## How it works + +``` +GitHub webhook ──► ingestWebhook ──► /github/pulls/42.json + │ + readFile ◄────────────┘ +``` + +`computeCanonicalPath("github", "pulls", "42")` always returns +`/github/pulls/42.json` — every agent reading or writing that PR +agrees on the same path, making collaboration deterministic. + +The webhook payload is queued as an **envelope**. The server deduplicates +by `delivery_id`, coalesces rapid updates, and writes the final content +to the canonical path. diff --git a/examples/03-webhook-to-vfs/index.ts b/examples/03-webhook-to-vfs/index.ts new file mode 100644 index 0000000..778cf28 --- /dev/null +++ b/examples/03-webhook-to-vfs/index.ts @@ -0,0 +1,125 @@ +/** + * Example 03 — Webhook ingestion into the virtual filesystem + * + * Demonstrates how external events (e.g. a GitHub pull request) flow into + * relayfile via ingestWebhook, and how computeCanonicalPath maps provider + * objects to deterministic file paths. + * + * Run: npx tsx index.ts + * Env: RELAYFILE_TOKEN — JWT with sync:trigger + fs:read scopes + * WORKSPACE_ID — target workspace ID + */ + +import { + RelayFileClient, + computeCanonicalPath, +} from "@relayfile/sdk"; + +const token = process.env.RELAYFILE_TOKEN; +const workspaceId = process.env.WORKSPACE_ID ?? "ws_demo"; + +if (!token) { + console.error("Set RELAYFILE_TOKEN before running this example."); + process.exit(1); +} + +const client = new RelayFileClient({ token }); + +// ── 1. Show canonical path mapping ────────────────────────────────────────── + +console.log("── computeCanonicalPath examples ──"); + +const paths = [ + computeCanonicalPath("github", "pulls", "42"), + computeCanonicalPath("github", "issues", "108"), + computeCanonicalPath("slack", "messages", "C04QZ1234-1711700000.000100"), + computeCanonicalPath("zendesk", "tickets", "98765"), + computeCanonicalPath("stripe", "invoices", "inv_abc123"), +]; + +for (const p of paths) { + console.log(` ${p}`); +} + +// ── 2. Ingest a GitHub PR webhook ─────────────────────────────────────────── + +console.log("\n── ingestWebhook (GitHub PR opened) ──"); + +const prPayload = { + provider: "github", + event_type: "pull_request.opened", + path: computeCanonicalPath("github", "pulls", "42"), + delivery_id: `ghd_${Date.now()}`, + timestamp: new Date().toISOString(), + data: { + number: 42, + title: "Add JWT auth middleware", + state: "open", + user: { login: "khaliqgant" }, + head: { ref: "feat/jwt-auth", sha: "a1b2c3d" }, + base: { ref: "main", sha: "e4f5g6h" }, + body: "Implements JWT-based auth with refresh tokens.\n\nCloses #37", + created_at: "2026-03-29T10:00:00Z", + updated_at: "2026-03-29T10:00:00Z", + labels: [{ name: "auth" }, { name: "security" }], + requested_reviewers: [{ login: "reviewer-1" }], + }, +}; + +const ingestResult = await client.ingestWebhook({ + workspaceId, + ...prPayload, +}); + +console.log(` status : ${ingestResult.status}`); +console.log(` envelopeId : ${ingestResult.id}`); +console.log(` correlationId : ${ingestResult.correlationId}`); + +// ── 3. Ingest a second event (PR review submitted) ────────────────────────── + +console.log("\n── ingestWebhook (GitHub PR review submitted) ──"); + +const reviewResult = await client.ingestWebhook({ + workspaceId, + provider: "github", + event_type: "pull_request_review.submitted", + path: computeCanonicalPath("github", "pull_reviews", "42-1"), + delivery_id: `ghd_${Date.now()}_review`, + data: { + pull_number: 42, + review_id: 1, + state: "approved", + user: { login: "reviewer-1" }, + body: "LGTM — auth flow looks solid.", + submitted_at: "2026-03-29T11:30:00Z", + }, +}); + +console.log(` envelopeId : ${reviewResult.id}`); + +// ── 4. Read the ingested file back ────────────────────────────────────────── + +console.log("\n── readFile (the ingested PR) ──"); + +// Give the server a moment to process the envelope +await new Promise((r) => setTimeout(r, 1000)); + +try { + const pr = await client.readFile( + workspaceId, + computeCanonicalPath("github", "pulls", "42") + ); + console.log(` revision : ${pr.revision}`); + console.log(` provider : ${pr.provider}`); + console.log(` contentType : ${pr.contentType}`); + + const data = JSON.parse(pr.content); + console.log(` PR title : ${data.title}`); + console.log(` PR state : ${data.state}`); +} catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.log(` (file not yet available — ${msg})`); + console.log(" This is normal if the server is still processing the envelope."); +} + +console.log("\nDone."); diff --git a/examples/04-realtime-events/README.md b/examples/04-realtime-events/README.md new file mode 100644 index 0000000..1c0a46e --- /dev/null +++ b/examples/04-realtime-events/README.md @@ -0,0 +1,49 @@ +# 04 — Realtime events + +Watch for file changes in a workspace using event polling. + +## What it shows + +| Concept | Purpose | +|---------|---------| +| `getEvents` | Fetch filesystem events with cursor-based pagination | +| Cursor tracking | Resume from where you left off — never miss an event | +| Provider filtering | Watch only events from a specific provider | + +## Run + +```bash +export RELAYFILE_TOKEN="ey…" # JWT with fs:read scope +export WORKSPACE_ID="ws_demo" + +npx tsx index.ts +``` + +## Event types + +| Type | Trigger | +|------|---------| +| `file.created` | New file written or ingested | +| `file.updated` | Existing file content changed | +| `file.deleted` | File removed | +| `dir.created` | New directory created | +| `dir.deleted` | Directory removed | +| `sync.error` | Provider sync failure | + +## Polling pattern + +```typescript +let cursor: string | undefined; + +while (true) { + const feed = await client.getEvents(workspaceId, { cursor, limit: 50 }); + for (const evt of feed.events) { + // handle event + } + cursor = feed.nextCursor; + await sleep(2000); +} +``` + +Save `cursor` to disk if your agent restarts — you'll pick up exactly +where you left off with no duplicates. diff --git a/examples/04-realtime-events/index.ts b/examples/04-realtime-events/index.ts new file mode 100644 index 0000000..c0f85aa --- /dev/null +++ b/examples/04-realtime-events/index.ts @@ -0,0 +1,98 @@ +/** + * Example 04 — Watch for file changes via event polling + * + * Demonstrates how an agent can watch for filesystem changes using the + * getEvents polling API. Events are delivered in order with cursor-based + * pagination, so you never miss a change. + * + * Run: npx tsx index.ts + * Env: RELAYFILE_TOKEN — JWT with fs:read scope + * WORKSPACE_ID — target workspace ID + */ + +import { RelayFileClient, type FilesystemEvent } from "@relayfile/sdk"; + +const token = process.env.RELAYFILE_TOKEN; +const workspaceId = process.env.WORKSPACE_ID ?? "ws_demo"; + +if (!token) { + console.error("Set RELAYFILE_TOKEN before running this example."); + process.exit(1); +} + +const client = new RelayFileClient({ token }); + +// ── 1. Fetch recent events ────────────────────────────────────────────────── + +console.log("── getEvents (last 10) ──"); + +const initial = await client.getEvents(workspaceId, { limit: 10 }); + +if (initial.events.length === 0) { + console.log(" (no events yet — write some files first)"); +} else { + for (const evt of initial.events) { + printEvent(evt); + } +} + +// ── 2. Poll for new events ────────────────────────────────────────────────── + +console.log("\n── polling for new events (3 rounds, 2s apart) ──"); + +let cursor = initial.nextCursor; +const POLL_ROUNDS = 3; +const POLL_INTERVAL_MS = 2000; + +for (let round = 1; round <= POLL_ROUNDS; round++) { + await sleep(POLL_INTERVAL_MS); + console.log(`\n [poll ${round}/${POLL_ROUNDS}]`); + + const feed = await client.getEvents(workspaceId, { cursor, limit: 20 }); + + if (feed.events.length === 0) { + console.log(" (no new events)"); + } else { + for (const evt of feed.events) { + printEvent(evt); + } + } + + cursor = feed.nextCursor; +} + +// ── 3. Filter events by provider ──────────────────────────────────────────── + +console.log("\n── getEvents (provider=github) ──"); + +const githubEvents = await client.getEvents(workspaceId, { + provider: "github", + limit: 5, +}); + +if (githubEvents.events.length === 0) { + console.log(" (no github events)"); +} else { + for (const evt of githubEvents.events) { + printEvent(evt); + } +} + +console.log("\nDone."); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function printEvent(evt: FilesystemEvent) { + const ts = new Date(evt.timestamp).toLocaleTimeString(); + console.log( + ` ${ts} ${padRight(evt.type, 14)} ${evt.path} (${evt.origin})` + ); +} + +function padRight(s: string, len: number): string { + return s.length >= len ? s : s + " ".repeat(len - s.length); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/examples/05-relayauth-scoped-agent/README.md b/examples/05-relayauth-scoped-agent/README.md new file mode 100644 index 0000000..7fda175 --- /dev/null +++ b/examples/05-relayauth-scoped-agent/README.md @@ -0,0 +1,61 @@ +# 05 — Scoped agent permissions + +Demonstrate how relayfile tokens restrict agent access with path-based scopes. + +## What it shows + +| Concept | Purpose | +|---------|---------| +| Path-scoped tokens | `fs:read:/github/*` limits reads to one subtree | +| Write rejection | A read-only scope blocks writes with 403 | +| Cross-path rejection | Reading outside the allowed path returns 403 | +| `RelayFileApiError` | Catch and inspect permission errors | + +## Run + +```bash +# Generate scoped tokens with relayauth +export RELAYFILE_TOKEN_SCOPED=$(relayauth sign \ + --workspace ws_demo --agent reader \ + --scope "fs:read:/github/*") + +export RELAYFILE_TOKEN_ADMIN=$(relayauth sign \ + --workspace ws_demo --agent admin \ + --scope "fs:read" --scope "fs:write") + +export WORKSPACE_ID="ws_demo" + +npx tsx index.ts +``` + +## Scope format + +``` +plane:resource:action:path +``` + +| Scope | Grants | +|-------|--------| +| `fs:read` | Read all files | +| `fs:write` | Write all files | +| `fs:read:/github/*` | Read only `/github/` subtree | +| `fs:write:/reports/*` | Write only `/reports/` subtree | +| `sync:trigger` | Ingest webhooks, trigger syncs | +| `ops:read` | View operation status | + +## Token claims + +A relayfile JWT contains: + +```json +{ + "workspace_id": "ws_demo", + "agent_name": "reader", + "scopes": ["fs:read:/github/*"], + "aud": "relayfile", + "exp": 1743300000 +} +``` + +The server checks scopes on every request — agents can only access +what their token explicitly allows. diff --git a/examples/05-relayauth-scoped-agent/index.ts b/examples/05-relayauth-scoped-agent/index.ts new file mode 100644 index 0000000..a2a4070 --- /dev/null +++ b/examples/05-relayauth-scoped-agent/index.ts @@ -0,0 +1,145 @@ +/** + * Example 05 — Agent with scoped permissions + * + * Demonstrates how relayfile tokens restrict agent access using scopes. + * A token scoped to `fs:read:/github/*` can read GitHub files but cannot + * write to any path, and cannot read files outside /github/. + * + * This example uses two clients: + * 1. A scoped "reader" agent — can only read /github/* + * 2. A full-access "admin" agent — can read and write everything + * + * Run: npx tsx index.ts + * Env: RELAYFILE_TOKEN_SCOPED — JWT with scope fs:read:/github/* + * RELAYFILE_TOKEN_ADMIN — JWT with scopes fs:read + fs:write + * WORKSPACE_ID — target workspace ID + */ + +import { RelayFileClient, RelayFileApiError } from "@relayfile/sdk"; + +const scopedToken = process.env.RELAYFILE_TOKEN_SCOPED; +const adminToken = process.env.RELAYFILE_TOKEN_ADMIN; +const workspaceId = process.env.WORKSPACE_ID ?? "ws_demo"; + +if (!scopedToken || !adminToken) { + console.error( + "Set both RELAYFILE_TOKEN_SCOPED and RELAYFILE_TOKEN_ADMIN before running." + ); + console.error(""); + console.error("Generate tokens with relayauth:"); + console.error( + ' Scoped: relayauth sign --workspace ws_demo --agent reader --scope "fs:read:/github/*"' + ); + console.error( + ' Admin: relayauth sign --workspace ws_demo --agent admin --scope "fs:read" --scope "fs:write"' + ); + process.exit(1); +} + +const scopedClient = new RelayFileClient({ token: scopedToken }); +const adminClient = new RelayFileClient({ token: adminToken }); + +// ── 1. Admin writes a file the scoped agent can read ──────────────────────── + +console.log("── admin: writeFile /github/pulls/99.json ──"); + +await adminClient.writeFile({ + workspaceId, + path: "/github/pulls/99.json", + baseRevision: "*", + content: JSON.stringify( + { + number: 99, + title: "Upgrade to Node 22", + state: "open", + user: { login: "khaliqgant" }, + }, + null, + 2 + ), + contentType: "application/json", + semantics: { + properties: { status: "open", priority: "high" }, + relations: ["repo:relayfile"], + }, +}); + +console.log(" written."); + +// ── 2. Scoped agent reads /github/* — should succeed ──────────────────────── + +console.log("\n── scoped agent: readFile /github/pulls/99.json ──"); + +const pr = await scopedClient.readFile( + workspaceId, + "/github/pulls/99.json" +); +console.log(` title : ${JSON.parse(pr.content).title}`); +console.log(` revision : ${pr.revision}`); + +// ── 3. Scoped agent lists /github tree — should succeed ───────────────────── + +console.log("\n── scoped agent: listTree /github ──"); + +const tree = await scopedClient.listTree(workspaceId, { path: "/github" }); + +for (const entry of tree.entries) { + console.log(` ${entry.type === "dir" ? "📁" : "📄"} ${entry.path}`); +} + +// ── 4. Scoped agent tries to write — expect 403 ──────────────────────────── + +console.log("\n── scoped agent: writeFile (expect 403 Forbidden) ──"); + +try { + await scopedClient.writeFile({ + workspaceId, + path: "/github/pulls/99.json", + baseRevision: "*", + content: '{"hijacked": true}', + contentType: "application/json", + }); + console.log(" ERROR: write should have been rejected!"); +} catch (err) { + if (err instanceof RelayFileApiError && err.status === 403) { + console.log(` Blocked: ${err.message}`); + console.log(" (scope fs:read:/github/* does not grant write access)"); + } else { + throw err; + } +} + +// ── 5. Scoped agent tries to read outside /github — expect 403 ───────────── + +console.log("\n── scoped agent: readFile /agents/config.json (expect 403) ──"); + +// First, make sure the file exists +await adminClient.writeFile({ + workspaceId, + path: "/agents/config.json", + baseRevision: "*", + content: '{"secret": "do-not-leak"}', + contentType: "application/json", +}); + +try { + await scopedClient.readFile(workspaceId, "/agents/config.json"); + console.log(" ERROR: read should have been rejected!"); +} catch (err) { + if (err instanceof RelayFileApiError && err.status === 403) { + console.log(` Blocked: ${err.message}`); + console.log(" (scope fs:read:/github/* does not cover /agents/)"); + } else { + throw err; + } +} + +// ── Summary ───────────────────────────────────────────────────────────────── + +console.log("\n── Summary ──"); +console.log(" Scoped token (fs:read:/github/*):"); +console.log(" ✓ Read /github/pulls/99.json"); +console.log(" ✓ List /github/"); +console.log(" ✗ Write /github/pulls/99.json → 403"); +console.log(" ✗ Read /agents/config.json → 403"); +console.log("\nDone."); diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..90dc0db --- /dev/null +++ b/examples/README.md @@ -0,0 +1,43 @@ +# relayfile SDK examples + +End-to-end examples showing how agents interact with the relayfile virtual filesystem. + +## Prerequisites + +```bash +npm install @relayfile/sdk +npm install -D tsx +``` + +Each example reads configuration from environment variables: + +```bash +export RELAYFILE_TOKEN="ey…" # JWT for your agent +export WORKSPACE_ID="ws_demo" # target workspace +``` + +Generate tokens with [relayauth](https://github.com/AgentWorkforce/relayauth): + +```bash +relayauth sign --workspace ws_demo --agent my-agent --scope "fs:read" --scope "fs:write" +``` + +## Examples + +| # | Directory | What it shows | +|---|-----------|---------------| +| 01 | [agent-reads-files](./01-agent-reads-files/) | `listTree`, `readFile`, `queryFiles` — browse a workspace | +| 02 | [agent-writes-files](./02-agent-writes-files/) | `writeFile`, `bulkWrite`, optimistic locking, conflict detection | +| 03 | [webhook-to-vfs](./03-webhook-to-vfs/) | `ingestWebhook`, `computeCanonicalPath` — external events to files | +| 04 | [realtime-events](./04-realtime-events/) | `getEvents` polling — watch for file changes with cursors | +| 05 | [relayauth-scoped-agent](./05-relayauth-scoped-agent/) | Path-scoped tokens, 403 rejection, least-privilege agents | + +## Running + +```bash +cd examples/01-agent-reads-files +npx tsx index.ts +``` + +Each example is self-contained — run them in any order. +Examples 01 and 04 are read-only; the others write data. From 58f29d180039a7252546df5e6963e09019d8dc9a Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 29 Mar 2026 12:52:18 +0200 Subject: [PATCH 2/5] feat: add docker-compose quickstart Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 8 +++++ docker/Dockerfile.relayauth | 5 +++ docker/Dockerfile.relayfile | 12 +++++++ docker/README.md | 48 ++++++++++++++++++++++++++++ docker/docker-compose.yml | 42 +++++++++++++++++++++++++ docker/relayauth/server.js | 62 +++++++++++++++++++++++++++++++++++++ docker/seed.sh | 41 ++++++++++++++++++++++++ 7 files changed, 218 insertions(+) create mode 100644 docker/Dockerfile.relayauth create mode 100644 docker/Dockerfile.relayfile create mode 100644 docker/README.md create mode 100644 docker/docker-compose.yml create mode 100644 docker/relayauth/server.js create mode 100644 docker/seed.sh diff --git a/README.md b/README.md index 4ca59bb..d5d738c 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,14 @@ External Services relayfile Your Agents 4. **Agents read and write files** — that's their entire integration 5. When an agent writes to a writeback path, the adapter posts the change back to the source API +## Quick Start (Docker) + +```bash +cd docker && docker compose up --build +``` + +This starts relayfile on `:9090` and relayauth on `:9091`, seeds a `ws_demo` workspace with sample files, and prints a dev token. See [`docker/README.md`](docker/README.md) for details. + ## Getting Started **[Relayfile Cloud](https://relayfile.dev/pricing)** — everything managed. Sign up, get a token, connect your services from the dashboard. diff --git a/docker/Dockerfile.relayauth b/docker/Dockerfile.relayauth new file mode 100644 index 0000000..c2ea638 --- /dev/null +++ b/docker/Dockerfile.relayauth @@ -0,0 +1,5 @@ +FROM node:20-alpine +WORKDIR /app +COPY relayauth/server.js . +EXPOSE 9091 +CMD ["node", "server.js"] diff --git a/docker/Dockerfile.relayfile b/docker/Dockerfile.relayfile new file mode 100644 index 0000000..3c95e2d --- /dev/null +++ b/docker/Dockerfile.relayfile @@ -0,0 +1,12 @@ +FROM golang:1.22-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /out/relayfile ./cmd/relayfile + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates curl +COPY --from=build /out/relayfile /usr/local/bin/relayfile +EXPOSE 8080 +CMD ["/usr/local/bin/relayfile"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..108c73f --- /dev/null +++ b/docker/README.md @@ -0,0 +1,48 @@ +# Docker Quickstart + +Run relayfile + relayauth locally in under a minute. + +## Start + +```bash +cd docker +docker compose up --build +``` + +This starts: + +| Service | URL | Description | +|---------|-----|-------------| +| relayfile | http://localhost:9090 | VFS API server (Go, in-memory) | +| relayauth | http://localhost:9091 | Token signing service (Node) | +| seed | (runs once) | Creates `ws_demo` workspace with sample files | + +The `seed` container mints a dev token and writes three sample files. Watch its logs for the token and a ready-to-paste `curl` command. + +## Usage + +```bash +# Grab the token from seed logs +TOKEN=$(docker compose logs seed | grep 'token' | tail -1 | awk '{print $NF}') + +# List files +curl -H "Authorization: Bearer $TOKEN" http://localhost:9090/v1/workspaces/ws_demo/fs/tree + +# Read a file +curl -H "Authorization: Bearer $TOKEN" "http://localhost:9090/v1/workspaces/ws_demo/fs/file?path=/docs/welcome.md" + +# Mint a new token +curl -X POST http://localhost:9091/sign \ + -H "Content-Type: application/json" \ + -d '{"workspace_id":"ws_demo","agent_name":"my-agent","scopes":["fs:read"]}' +``` + +## Configuration + +Copy `.env.example` to `.env` to override defaults. The in-memory backend means data resets on restart — perfect for development. + +## Teardown + +```bash +docker compose down +``` diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..b3a37ca --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,42 @@ +services: + relayfile: + build: + context: .. + dockerfile: docker/Dockerfile.relayfile + ports: + - "${RELAYFILE_PORT:-9090}:8080" + environment: + RELAYFILE_ADDR: :8080 + RELAYFILE_BACKEND_PROFILE: memory + RELAYFILE_JWT_SECRET: ${RELAYFILE_JWT_SECRET:-dev-secret} + RELAYFILE_INTERNAL_HMAC_SECRET: ${RELAYFILE_INTERNAL_HMAC_SECRET:-dev-internal-secret} + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] + interval: 3s + timeout: 3s + retries: 10 + + relayauth: + build: + context: . + dockerfile: Dockerfile.relayauth + ports: + - "${RELAYAUTH_PORT:-9091}:9091" + environment: + RELAYFILE_JWT_SECRET: ${RELAYFILE_JWT_SECRET:-dev-secret} + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:9091/health"] + interval: 3s + timeout: 3s + retries: 10 + + seed: + image: curlimages/curl:latest + depends_on: + relayfile: + condition: service_healthy + relayauth: + condition: service_healthy + volumes: + - ./seed.sh:/seed.sh:ro + entrypoint: ["/bin/sh", "/seed.sh"] diff --git a/docker/relayauth/server.js b/docker/relayauth/server.js new file mode 100644 index 0000000..6c1ff34 --- /dev/null +++ b/docker/relayauth/server.js @@ -0,0 +1,62 @@ +const http = require("node:http"); +const crypto = require("node:crypto"); + +const PORT = 9091; +const SECRET = process.env.RELAYFILE_JWT_SECRET || "dev-secret"; + +function b64url(buf) { + return Buffer.from(buf) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function sign(payload) { + const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const body = b64url(JSON.stringify(payload)); + const sig = b64url( + crypto.createHmac("sha256", SECRET).update(`${header}.${body}`).digest() + ); + return `${header}.${body}.${sig}`; +} + +const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end('{"status":"ok"}'); + } + + if (req.method === "POST" && req.url === "/sign") { + let data = ""; + req.on("data", (c) => (data += c)); + req.on("end", () => { + try { + const { workspace_id, agent_name, scopes } = JSON.parse(data); + const token = sign({ + workspace_id: workspace_id || "ws_demo", + agent_name: agent_name || "dev-agent", + scopes: scopes || [ + "fs:read", + "fs:write", + "sync:read", + "ops:read", + ], + exp: 4102444800, + aud: "relayfile", + }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ token })); + } catch { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end('{"error":"invalid json body"}'); + } + }); + return; + } + + res.writeHead(404); + res.end("Not found"); +}); + +server.listen(PORT, () => console.log(`relayauth listening on :${PORT}`)); diff --git a/docker/seed.sh b/docker/seed.sh new file mode 100644 index 0000000..b5bc529 --- /dev/null +++ b/docker/seed.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -e + +RELAYFILE=http://relayfile:8080 +RELAYAUTH=http://relayauth:9091 +WS=ws_demo + +echo "==> Minting dev token via relayauth..." +TOKEN=$(curl -sS "$RELAYAUTH/sign" \ + -H "Content-Type: application/json" \ + -d "{\"workspace_id\":\"$WS\",\"agent_name\":\"dev-agent\",\"scopes\":[\"fs:read\",\"fs:write\",\"sync:read\",\"ops:read\"]}" \ + | sed 's/.*"token":"\([^"]*\)".*/\1/') + +echo "==> Seeding sample files into workspace $WS..." + +curl -sS -X PUT "$RELAYFILE/v1/workspaces/$WS/fs/file?path=/docs/welcome.md" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/octet-stream" \ + -d '# Welcome +Your relayfile workspace is running. +Write files here and agents will see them instantly.' + +curl -sS -X PUT "$RELAYFILE/v1/workspaces/$WS/fs/file?path=/github/repos/demo/pulls/1/metadata.json" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/octet-stream" \ + -d '{"number":1,"title":"Add quickstart","state":"open","author":"dev"}' + +curl -sS -X PUT "$RELAYFILE/v1/workspaces/$WS/fs/file?path=/config/agents.json" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/octet-stream" \ + -d '{"agents":[{"name":"dev-agent","scopes":["fs:read","fs:write"]}]}' + +echo "" +echo "=== Ready ===" +echo " relayfile : http://localhost:${RELAYFILE_PORT:-9090}" +echo " relayauth : http://localhost:${RELAYAUTH_PORT:-9091}" +echo " workspace : $WS" +echo " token : $TOKEN" +echo "" +echo "Try it:" +echo " curl -H \"Authorization: Bearer $TOKEN\" http://localhost:${RELAYFILE_PORT:-9090}/v1/workspaces/$WS/fs/tree" From eedad83a95898c78f4cf09e1a174398d7978298c Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 29 Mar 2026 12:56:23 +0200 Subject: [PATCH 3/5] docs: update examples with ecosystem integration Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/01-agent-reads-files/README.md | 5 + examples/02-agent-writes-files/README.md | 5 + examples/03-webhook-to-vfs/README.md | 5 + examples/04-realtime-events/README.md | 5 + examples/05-relayauth-scoped-agent/README.md | 5 + examples/06-writeback-consumer/README.md | 42 +++++++ examples/06-writeback-consumer/index.ts | 116 +++++++++++++++++++ examples/README.md | 4 + 8 files changed, 187 insertions(+) create mode 100644 examples/06-writeback-consumer/README.md create mode 100644 examples/06-writeback-consumer/index.ts diff --git a/examples/01-agent-reads-files/README.md b/examples/01-agent-reads-files/README.md index d5e7bb0..e118ce0 100644 --- a/examples/01-agent-reads-files/README.md +++ b/examples/01-agent-reads-files/README.md @@ -10,6 +10,11 @@ The simplest relayfile example: connect to a workspace and read its contents. | `readFile` | Fetch a single file's content and metadata | | `queryFiles` | Search files by provider or semantic properties | +## Prerequisites + +- **Docker** — needed to run the relayfile server locally (`docker compose up`) +- Node.js 18+, `tsx` + ## Run ```bash diff --git a/examples/02-agent-writes-files/README.md b/examples/02-agent-writes-files/README.md index c97aa54..1ee9ca9 100644 --- a/examples/02-agent-writes-files/README.md +++ b/examples/02-agent-writes-files/README.md @@ -12,6 +12,11 @@ Write files into the relayfile virtual filesystem, then read them back. | `bulkWrite` | Atomically write multiple files in one request | | `readFile` | Verify written content | +## Prerequisites + +- **Docker** — needed to run the relayfile server locally (`docker compose up`) +- Node.js 18+, `tsx` + ## Run ```bash diff --git a/examples/03-webhook-to-vfs/README.md b/examples/03-webhook-to-vfs/README.md index 8a91aa4..9fc0d16 100644 --- a/examples/03-webhook-to-vfs/README.md +++ b/examples/03-webhook-to-vfs/README.md @@ -10,6 +10,11 @@ Ingest external webhooks into the relayfile virtual filesystem. | `ingestWebhook` | Push external events (GitHub, Slack, etc.) into the VFS | | `readFile` | Read back the ingested data as a file | +## Prerequisites + +- **Docker** — needed to run the relayfile server locally (`docker compose up`) +- Node.js 18+, `tsx` + ## Run ```bash diff --git a/examples/04-realtime-events/README.md b/examples/04-realtime-events/README.md index 1c0a46e..f744747 100644 --- a/examples/04-realtime-events/README.md +++ b/examples/04-realtime-events/README.md @@ -10,6 +10,11 @@ Watch for file changes in a workspace using event polling. | Cursor tracking | Resume from where you left off — never miss an event | | Provider filtering | Watch only events from a specific provider | +## Prerequisites + +- **Docker** — needed to run the relayfile server locally (`docker compose up`) +- Node.js 18+, `tsx` + ## Run ```bash diff --git a/examples/05-relayauth-scoped-agent/README.md b/examples/05-relayauth-scoped-agent/README.md index 7fda175..89e0d7d 100644 --- a/examples/05-relayauth-scoped-agent/README.md +++ b/examples/05-relayauth-scoped-agent/README.md @@ -11,6 +11,11 @@ Demonstrate how relayfile tokens restrict agent access with path-based scopes. | Cross-path rejection | Reading outside the allowed path returns 403 | | `RelayFileApiError` | Catch and inspect permission errors | +## Prerequisites + +- **Docker** — needed to run the relayfile server locally (`docker compose up`) +- Node.js 18+, `tsx` + ## Run ```bash diff --git a/examples/06-writeback-consumer/README.md b/examples/06-writeback-consumer/README.md new file mode 100644 index 0000000..a807210 --- /dev/null +++ b/examples/06-writeback-consumer/README.md @@ -0,0 +1,42 @@ +# 06 — Writeback consumer + +Poll pending writebacks and push changes back to the external provider (GitHub). + +## What it shows + +| Concept | Purpose | +|---------|---------| +| `listPendingWritebacks` | Fetch items waiting to be written back to a provider | +| `readFile` | Read VFS file content for the writeback payload | +| `ackWriteback` | Acknowledge success or failure after provider push | +| GitHub writeback handler | Map VFS paths to GitHub API calls (issues, PRs, comments) | + +## Prerequisites + +- **Docker** — needed to run the relayfile server locally (`docker compose up`) +- Node.js 18+, `tsx` + +## Run + +```bash +export RELAYFILE_TOKEN="ey…" # JWT with fs:read + ops:read scopes +export WORKSPACE_ID="ws_demo" +export GITHUB_TOKEN="ghp_…" # PAT with repo scope + +npx tsx index.ts +``` + +## How it works + +``` +VFS file changed ──► listPendingWritebacks ──► readFile + │ + GitHub API ◄── handler routes ◄─┘ + │ + ackWriteback (success / failure) +``` + +The consumer polls for pending writebacks, reads the file content, +dispatches to the appropriate GitHub handler based on the VFS path, +then acknowledges the result. Failed items are retried automatically +up to `MAX_WRITEBACK_ATTEMPTS` before being dead-lettered. diff --git a/examples/06-writeback-consumer/index.ts b/examples/06-writeback-consumer/index.ts new file mode 100644 index 0000000..a8751de --- /dev/null +++ b/examples/06-writeback-consumer/index.ts @@ -0,0 +1,116 @@ +/** + * Example 06 — Writeback consumer with GitHub handler + * + * Polls for pending writebacks and pushes VFS changes back to GitHub + * via the GitHub API. Demonstrates the full writeback lifecycle: + * list → read → push → acknowledge. + * + * Run: npx tsx index.ts + * Env: RELAYFILE_TOKEN — JWT with fs:read + ops:read scopes + * WORKSPACE_ID — target workspace ID + * GITHUB_TOKEN — GitHub PAT with repo scope + */ + +import { RelayFileClient } from "@relayfile/sdk"; + +const token = process.env.RELAYFILE_TOKEN; +const workspaceId = process.env.WORKSPACE_ID ?? "ws_demo"; +const githubToken = process.env.GITHUB_TOKEN; + +if (!token || !githubToken) { + console.error("Set RELAYFILE_TOKEN and GITHUB_TOKEN before running."); + process.exit(1); +} + +const client = new RelayFileClient({ token }); + +// ── 1. GitHub writeback handler ──────────────────────────────────────────── + +interface WritebackRoute { + owner: string; + repo: string; + resource: string; + id: string; +} + +function parseGitHubPath(path: string): WritebackRoute | null { + // /github/{owner}/{repo}/{resource}/{id}.json + const match = path.match(/^\/github\/([^/]+)\/([^/]+)\/([^/]+)\/(\d+)\.json$/); + if (!match) return null; + return { owner: match[1], repo: match[2], resource: match[3], id: match[4] }; +} + +async function pushToGitHub(route: WritebackRoute, content: string): Promise { + const body = JSON.parse(content); + const { owner, repo, resource, id } = route; + + const endpoint = + resource === "issues" ? `repos/${owner}/${repo}/issues/${id}` : + resource === "pulls" ? `repos/${owner}/${repo}/pulls/${id}` : + resource === "comments" ? `repos/${owner}/${repo}/issues/comments/${id}` : + null; + + if (!endpoint) { + throw new Error(`Unsupported GitHub resource: ${resource}`); + } + + const resp = await fetch(`https://api.github.com/${endpoint}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${githubToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + const err = await resp.text(); + throw new Error(`GitHub API ${resp.status}: ${err.slice(0, 200)}`); + } +} + +// ── 2. Poll and process pending writebacks ───────────────────────────────── + +console.log("── listPendingWritebacks ──"); +const pending = await client.listPendingWritebacks(workspaceId); +console.log(` ${pending.length} pending writeback(s)\n`); + +for (const item of pending) { + console.log(`── processing ${item.path} (id=${item.id}) ──`); + + const route = parseGitHubPath(item.path); + if (!route) { + console.log(` skipping — not a GitHub path`); + await client.ackWriteback({ + workspaceId, + itemId: item.id, + success: false, + error: "Unrecognized path format", + }); + continue; + } + + // ── 3. Read file content and push to GitHub ────────────────────────────── + + try { + const file = await client.readFile(workspaceId, item.path); + console.log(` read ${file.path} rev=${file.revision}`); + + await pushToGitHub(route, file.content); + console.log(` pushed to GitHub ${route.owner}/${route.repo}/${route.resource}/${route.id}`); + + await client.ackWriteback({ workspaceId, itemId: item.id, success: true }); + console.log(` ack: success`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.log(` ack: failed — ${message}`); + await client.ackWriteback({ + workspaceId, + itemId: item.id, + success: false, + error: message, + }); + } +} + +console.log("\nDone."); diff --git a/examples/README.md b/examples/README.md index 90dc0db..4ccf53e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,6 +4,9 @@ End-to-end examples showing how agents interact with the relayfile virtual files ## Prerequisites +- **Docker** — needed to run the relayfile server locally (`docker compose up`) +- Node.js 18+ + ```bash npm install @relayfile/sdk npm install -D tsx @@ -31,6 +34,7 @@ relayauth sign --workspace ws_demo --agent my-agent --scope "fs:read" --scope "f | 03 | [webhook-to-vfs](./03-webhook-to-vfs/) | `ingestWebhook`, `computeCanonicalPath` — external events to files | | 04 | [realtime-events](./04-realtime-events/) | `getEvents` polling — watch for file changes with cursors | | 05 | [relayauth-scoped-agent](./05-relayauth-scoped-agent/) | Path-scoped tokens, 403 rejection, least-privilege agents | +| 06 | [writeback-consumer](./06-writeback-consumer/) | `listPendingWritebacks`, `ackWriteback` — push VFS changes back to GitHub | ## Running From 2a216d064351c93f9cb7a924f2357926eb6ad58b Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 29 Mar 2026 14:51:12 +0200 Subject: [PATCH 4/5] docs: update examples with ecosystem integration --- examples/01-agent-reads-files/README.md | 12 +- examples/02-agent-writes-files/README.md | 12 +- examples/03-webhook-to-vfs/README.md | 12 +- examples/04-realtime-events/README.md | 12 +- examples/05-relayauth-scoped-agent/README.md | 12 +- examples/06-writeback-consumer/README.md | 23 ++- examples/06-writeback-consumer/index.ts | 187 ++++++++++++------- examples/README.md | 15 +- 8 files changed, 208 insertions(+), 77 deletions(-) diff --git a/examples/01-agent-reads-files/README.md b/examples/01-agent-reads-files/README.md index e118ce0..a4db6bb 100644 --- a/examples/01-agent-reads-files/README.md +++ b/examples/01-agent-reads-files/README.md @@ -12,9 +12,19 @@ The simplest relayfile example: connect to a workspace and read its contents. ## Prerequisites -- **Docker** — needed to run the relayfile server locally (`docker compose up`) +- Docker Engine or Docker Desktop with the Compose plugin - Node.js 18+, `tsx` +Start the local stack from the repo root before running this example: + +```bash +cd docker +docker compose up --build +``` + +This boots the relayfile API, relayauth, and the seeded `ws_demo` +workspace used throughout the examples. + ## Run ```bash diff --git a/examples/02-agent-writes-files/README.md b/examples/02-agent-writes-files/README.md index 1ee9ca9..01e4764 100644 --- a/examples/02-agent-writes-files/README.md +++ b/examples/02-agent-writes-files/README.md @@ -14,9 +14,19 @@ Write files into the relayfile virtual filesystem, then read them back. ## Prerequisites -- **Docker** — needed to run the relayfile server locally (`docker compose up`) +- Docker Engine or Docker Desktop with the Compose plugin - Node.js 18+, `tsx` +Start the local stack from the repo root before running this example: + +```bash +cd docker +docker compose up --build +``` + +This boots the relayfile API, relayauth, and the seeded `ws_demo` +workspace used throughout the examples. + ## Run ```bash diff --git a/examples/03-webhook-to-vfs/README.md b/examples/03-webhook-to-vfs/README.md index 9fc0d16..2e889cd 100644 --- a/examples/03-webhook-to-vfs/README.md +++ b/examples/03-webhook-to-vfs/README.md @@ -12,9 +12,19 @@ Ingest external webhooks into the relayfile virtual filesystem. ## Prerequisites -- **Docker** — needed to run the relayfile server locally (`docker compose up`) +- Docker Engine or Docker Desktop with the Compose plugin - Node.js 18+, `tsx` +Start the local stack from the repo root before running this example: + +```bash +cd docker +docker compose up --build +``` + +This boots the relayfile API, relayauth, and the seeded `ws_demo` +workspace used throughout the examples. + ## Run ```bash diff --git a/examples/04-realtime-events/README.md b/examples/04-realtime-events/README.md index f744747..be33163 100644 --- a/examples/04-realtime-events/README.md +++ b/examples/04-realtime-events/README.md @@ -12,9 +12,19 @@ Watch for file changes in a workspace using event polling. ## Prerequisites -- **Docker** — needed to run the relayfile server locally (`docker compose up`) +- Docker Engine or Docker Desktop with the Compose plugin - Node.js 18+, `tsx` +Start the local stack from the repo root before running this example: + +```bash +cd docker +docker compose up --build +``` + +This boots the relayfile API, relayauth, and the seeded `ws_demo` +workspace used throughout the examples. + ## Run ```bash diff --git a/examples/05-relayauth-scoped-agent/README.md b/examples/05-relayauth-scoped-agent/README.md index 89e0d7d..f2f7322 100644 --- a/examples/05-relayauth-scoped-agent/README.md +++ b/examples/05-relayauth-scoped-agent/README.md @@ -13,9 +13,19 @@ Demonstrate how relayfile tokens restrict agent access with path-based scopes. ## Prerequisites -- **Docker** — needed to run the relayfile server locally (`docker compose up`) +- Docker Engine or Docker Desktop with the Compose plugin - Node.js 18+, `tsx` +Start the local stack from the repo root before running this example: + +```bash +cd docker +docker compose up --build +``` + +This boots the relayfile API, relayauth, and the seeded `ws_demo` +workspace used throughout the examples. + ## Run ```bash diff --git a/examples/06-writeback-consumer/README.md b/examples/06-writeback-consumer/README.md index a807210..6b77e59 100644 --- a/examples/06-writeback-consumer/README.md +++ b/examples/06-writeback-consumer/README.md @@ -9,13 +9,24 @@ Poll pending writebacks and push changes back to the external provider (GitHub). | `listPendingWritebacks` | Fetch items waiting to be written back to a provider | | `readFile` | Read VFS file content for the writeback payload | | `ackWriteback` | Acknowledge success or failure after provider push | -| GitHub writeback handler | Map VFS paths to GitHub API calls (issues, PRs, comments) | +| `WritebackConsumer` | Poll relayfile, dispatch pending items, and ack outcomes | +| `GitHubWritebackHandler` | Map VFS paths to GitHub API calls (issues, PRs, comments) | ## Prerequisites -- **Docker** — needed to run the relayfile server locally (`docker compose up`) +- Docker Engine or Docker Desktop with the Compose plugin - Node.js 18+, `tsx` +Start the local stack from the repo root before running this example: + +```bash +cd docker +docker compose up --build +``` + +This boots the relayfile API, relayauth, and the seeded `ws_demo` +workspace used throughout the examples. + ## Run ```bash @@ -36,7 +47,7 @@ VFS file changed ──► listPendingWritebacks ──► readFile ackWriteback (success / failure) ``` -The consumer polls for pending writebacks, reads the file content, -dispatches to the appropriate GitHub handler based on the VFS path, -then acknowledges the result. Failed items are retried automatically -up to `MAX_WRITEBACK_ATTEMPTS` before being dead-lettered. +`WritebackConsumer` polls for pending writebacks, reads the file content, +dispatches to `GitHubWritebackHandler` based on the VFS path, then +acknowledges the result. Failed items are retried automatically up to +`MAX_WRITEBACK_ATTEMPTS` before being dead-lettered. diff --git a/examples/06-writeback-consumer/index.ts b/examples/06-writeback-consumer/index.ts index a8751de..77e29b6 100644 --- a/examples/06-writeback-consumer/index.ts +++ b/examples/06-writeback-consumer/index.ts @@ -3,7 +3,7 @@ * * Polls for pending writebacks and pushes VFS changes back to GitHub * via the GitHub API. Demonstrates the full writeback lifecycle: - * list → read → push → acknowledge. + * list → read → dispatch → acknowledge. * * Run: npx tsx index.ts * Env: RELAYFILE_TOKEN — JWT with fs:read + ops:read scopes @@ -11,7 +11,11 @@ * GITHUB_TOKEN — GitHub PAT with repo scope */ -import { RelayFileClient } from "@relayfile/sdk"; +import { + RelayFileClient, + type FileReadResponse, + type WritebackItem, +} from "@relayfile/sdk"; const token = process.env.RELAYFILE_TOKEN; const workspaceId = process.env.WORKSPACE_ID ?? "ws_demo"; @@ -24,8 +28,6 @@ if (!token || !githubToken) { const client = new RelayFileClient({ token }); -// ── 1. GitHub writeback handler ──────────────────────────────────────────── - interface WritebackRoute { owner: string; repo: string; @@ -33,79 +35,131 @@ interface WritebackRoute { id: string; } -function parseGitHubPath(path: string): WritebackRoute | null { - // /github/{owner}/{repo}/{resource}/{id}.json - const match = path.match(/^\/github\/([^/]+)\/([^/]+)\/([^/]+)\/(\d+)\.json$/); - if (!match) return null; - return { owner: match[1], repo: match[2], resource: match[3], id: match[4] }; +interface WritebackHandler { + canHandle(path: string): boolean; + handle(item: WritebackItem, file: FileReadResponse): Promise; } -async function pushToGitHub(route: WritebackRoute, content: string): Promise { - const body = JSON.parse(content); - const { owner, repo, resource, id } = route; - - const endpoint = - resource === "issues" ? `repos/${owner}/${repo}/issues/${id}` : - resource === "pulls" ? `repos/${owner}/${repo}/pulls/${id}` : - resource === "comments" ? `repos/${owner}/${repo}/issues/comments/${id}` : - null; - - if (!endpoint) { - throw new Error(`Unsupported GitHub resource: ${resource}`); - } +class GitHubWritebackHandler implements WritebackHandler { + constructor( + private readonly accessToken: string, + private readonly fetchImpl: typeof fetch = fetch, + ) {} - const resp = await fetch(`https://api.github.com/${endpoint}`, { - method: "PATCH", - headers: { - Authorization: `Bearer ${githubToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - - if (!resp.ok) { - const err = await resp.text(); - throw new Error(`GitHub API ${resp.status}: ${err.slice(0, 200)}`); + canHandle(path: string): boolean { + return this.parsePath(path) !== null; } -} - -// ── 2. Poll and process pending writebacks ───────────────────────────────── -console.log("── listPendingWritebacks ──"); -const pending = await client.listPendingWritebacks(workspaceId); -console.log(` ${pending.length} pending writeback(s)\n`); + async handle(item: WritebackItem, file: FileReadResponse): Promise { + const route = this.parsePath(item.path); + if (!route) { + throw new Error(`Unsupported GitHub writeback path: ${item.path}`); + } + + const body = JSON.parse(file.content); + const endpoint = this.buildEndpoint(route); + + const response = await this.fetchImpl(`https://api.github.com/${endpoint}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${this.accessToken}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); -for (const item of pending) { - console.log(`── processing ${item.path} (id=${item.id}) ──`); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`GitHub API ${response.status}: ${errorText.slice(0, 200)}`); + } + } - const route = parseGitHubPath(item.path); - if (!route) { - console.log(` skipping — not a GitHub path`); - await client.ackWriteback({ - workspaceId, - itemId: item.id, - success: false, - error: "Unrecognized path format", - }); - continue; + private parsePath(path: string): WritebackRoute | null { + // /github/{owner}/{repo}/{resource}/{id}.json + const match = path.match( + /^\/github\/([^/]+)\/([^/]+)\/([^/]+)\/(\d+)\.json$/, + ); + if (!match) { + return null; + } + + return { + owner: match[1], + repo: match[2], + resource: match[3], + id: match[4], + }; } - // ── 3. Read file content and push to GitHub ────────────────────────────── + private buildEndpoint(route: WritebackRoute): string { + const { owner, repo, resource, id } = route; + + switch (resource) { + case "issues": + return `repos/${owner}/${repo}/issues/${id}`; + case "pulls": + return `repos/${owner}/${repo}/pulls/${id}`; + case "comments": + return `repos/${owner}/${repo}/issues/comments/${id}`; + default: + throw new Error(`Unsupported GitHub resource: ${resource}`); + } + } +} - try { - const file = await client.readFile(workspaceId, item.path); - console.log(` read ${file.path} rev=${file.revision}`); +class WritebackConsumer { + constructor( + private readonly relayfile: RelayFileClient, + private readonly workspaceId: string, + private readonly handlers: WritebackHandler[], + ) {} + + async runOnce(): Promise { + console.log("── listPendingWritebacks ──"); + const pending = await this.relayfile.listPendingWritebacks(this.workspaceId); + console.log(` ${pending.length} pending writeback(s)\n`); + + for (const item of pending) { + await this.processItem(item); + } + } - await pushToGitHub(route, file.content); - console.log(` pushed to GitHub ${route.owner}/${route.repo}/${route.resource}/${route.id}`); + private async processItem(item: WritebackItem): Promise { + console.log(`── processing ${item.path} (id=${item.id}) ──`); + + const handler = this.handlers.find((candidate) => + candidate.canHandle(item.path), + ); + + if (!handler) { + await this.fail(item, "No handler registered for this path"); + return; + } + + try { + const file = await this.relayfile.readFile(this.workspaceId, item.path); + console.log(` read ${file.path} rev=${file.revision}`); + + await handler.handle(item, file); + console.log(" pushed to upstream provider"); + + await this.relayfile.ackWriteback({ + workspaceId: this.workspaceId, + itemId: item.id, + success: true, + }); + console.log(" ack: success"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await this.fail(item, message); + } + } - await client.ackWriteback({ workspaceId, itemId: item.id, success: true }); - console.log(` ack: success`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); + private async fail(item: WritebackItem, message: string): Promise { console.log(` ack: failed — ${message}`); - await client.ackWriteback({ - workspaceId, + await this.relayfile.ackWriteback({ + workspaceId: this.workspaceId, itemId: item.id, success: false, error: message, @@ -113,4 +167,9 @@ for (const item of pending) { } } +const consumer = new WritebackConsumer(client, workspaceId, [ + new GitHubWritebackHandler(githubToken), +]); + +await consumer.runOnce(); console.log("\nDone."); diff --git a/examples/README.md b/examples/README.md index 4ccf53e..71b4c73 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,7 +4,7 @@ End-to-end examples showing how agents interact with the relayfile virtual files ## Prerequisites -- **Docker** — needed to run the relayfile server locally (`docker compose up`) +- Docker Engine or Docker Desktop with the Compose plugin - Node.js 18+ ```bash @@ -12,6 +12,17 @@ npm install @relayfile/sdk npm install -D tsx ``` +Start the local relayfile stack before running any example: + +```bash +cd docker +docker compose up --build +``` + +That brings up `relayfile` on `http://localhost:9090`, `relayauth` on +`http://localhost:9091`, and seeds `ws_demo` with sample files. See +[`docker/README.md`](../docker/README.md) for the full local setup. + Each example reads configuration from environment variables: ```bash @@ -34,7 +45,7 @@ relayauth sign --workspace ws_demo --agent my-agent --scope "fs:read" --scope "f | 03 | [webhook-to-vfs](./03-webhook-to-vfs/) | `ingestWebhook`, `computeCanonicalPath` — external events to files | | 04 | [realtime-events](./04-realtime-events/) | `getEvents` polling — watch for file changes with cursors | | 05 | [relayauth-scoped-agent](./05-relayauth-scoped-agent/) | Path-scoped tokens, 403 rejection, least-privilege agents | -| 06 | [writeback-consumer](./06-writeback-consumer/) | `listPendingWritebacks`, `ackWriteback` — push VFS changes back to GitHub | +| 06 | [writeback-consumer](./06-writeback-consumer/) | `WritebackConsumer` + `GitHubWritebackHandler` — push VFS changes back to GitHub | ## Running From fdcfd3d7c3a56c6128f78b5a9391518fc722438c Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 29 Mar 2026 23:37:35 +0200 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20address=20Devin=20review=20=E2=80=94?= =?UTF-8?q?=20evt.origin=20fallback,=20scope=20format,=20correlation=20IDs?= =?UTF-8?q?=20in=20seed.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/seed.sh | 7 +++++-- examples/04-realtime-events/index.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docker/seed.sh b/docker/seed.sh index b5bc529..401369a 100644 --- a/docker/seed.sh +++ b/docker/seed.sh @@ -8,7 +8,7 @@ WS=ws_demo echo "==> Minting dev token via relayauth..." TOKEN=$(curl -sS "$RELAYAUTH/sign" \ -H "Content-Type: application/json" \ - -d "{\"workspace_id\":\"$WS\",\"agent_name\":\"dev-agent\",\"scopes\":[\"fs:read\",\"fs:write\",\"sync:read\",\"ops:read\"]}" \ + -d "{\"workspace_id\":\"$WS\",\"agent_name\":\"dev-agent\",\"scopes\":[\"relayfile:fs:read:*\",\"relayfile:fs:write:*\",\"relayfile:sync:read:*\",\"relayfile:ops:read:*\"]}" \ | sed 's/.*"token":"\([^"]*\)".*/\1/') echo "==> Seeding sample files into workspace $WS..." @@ -16,6 +16,7 @@ echo "==> Seeding sample files into workspace $WS..." curl -sS -X PUT "$RELAYFILE/v1/workspaces/$WS/fs/file?path=/docs/welcome.md" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/octet-stream" \ + -H "X-Correlation-Id: seed-$(date +%s)-1" \ -d '# Welcome Your relayfile workspace is running. Write files here and agents will see them instantly.' @@ -23,12 +24,14 @@ Write files here and agents will see them instantly.' curl -sS -X PUT "$RELAYFILE/v1/workspaces/$WS/fs/file?path=/github/repos/demo/pulls/1/metadata.json" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/octet-stream" \ + -H "X-Correlation-Id: seed-$(date +%s)-2" \ -d '{"number":1,"title":"Add quickstart","state":"open","author":"dev"}' curl -sS -X PUT "$RELAYFILE/v1/workspaces/$WS/fs/file?path=/config/agents.json" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/octet-stream" \ - -d '{"agents":[{"name":"dev-agent","scopes":["fs:read","fs:write"]}]}' + -H "X-Correlation-Id: seed-$(date +%s)-3" \ + -d '{"agents":[{"name":"dev-agent","scopes":["relayfile:fs:read:*","relayfile:fs:write:*"]}]}' echo "" echo "=== Ready ===" diff --git a/examples/04-realtime-events/index.ts b/examples/04-realtime-events/index.ts index c0f85aa..5ccac59 100644 --- a/examples/04-realtime-events/index.ts +++ b/examples/04-realtime-events/index.ts @@ -85,7 +85,7 @@ console.log("\nDone."); function printEvent(evt: FilesystemEvent) { const ts = new Date(evt.timestamp).toLocaleTimeString(); console.log( - ` ${ts} ${padRight(evt.type, 14)} ${evt.path} (${evt.origin})` + ` ${ts} ${padRight(evt.type, 14)} ${evt.path} (${evt.origin ?? "unknown"})` ); }