diff --git a/README.md b/README.md index 6014ec8..5b40232 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,14 @@ A component is a function that returns one or more emits. Three shapes: - **Capability** — emits tools and optionally a fragment describing them (``, ``). - **Shaper** — wraps children and transforms what they emit (``). +Built-in capability components include ``, ``, ``, ``, ``, ``, ``, ``. Drop any of them inside ``: + +```tsx + + + +``` + A minimal capability: ```tsx diff --git a/examples/goal-test/cli.tsx b/examples/goal-test/cli.tsx new file mode 100644 index 0000000..73146eb --- /dev/null +++ b/examples/goal-test/cli.tsx @@ -0,0 +1,198 @@ +// Userspace emulation of Claude Code's /goal: +// +// 1. Set a natural-language condition. +// 2. Run the agent. +// 3. When the agent halts, call a SEPARATE `infer` (the judge) to +// evaluate whether the condition holds against the transcript. +// 4. If the judge says ok=false, re-prompt the agent with the judge's +// reason. Loop until ok=true (or iteration budget is exhausted). +// +// No runtime modification: this is pure userspace orchestration around +// `agent.run` + `agent.events` + a second `infer` call. The judge is +// the same model family but isolated — it never sees the agent's tools, +// only the transcript. +// +// Run: +// infisical run --silent -- npx tsx cli.tsx +// Env: +// AI_GATEWAY_API_KEY=... + +import { NodeContext } from "@effect/platform-node" +import { + createAgentRuntime, + createAiGatewayInfer, + render, +} from "@flamecast/agentjsx" +import { + Agent, + Block, + Messages, + Workspace, +} from "@flamecast/agentjsx/components" +import type { Event, InferFn } from "@flamecast/agentjsx" + +// --- Config --------------------------------------------------------------- + +const GOAL = "The assistant has greeted the user in pirate speak (e.g. 'Ahoy')." +const INITIAL_PROMPT = "Greet me." +const MAX_ITERATIONS = 4 + +// --- Pretty-printing ------------------------------------------------------ + +const DIM = (s: string) => `\x1b[2m${s}\x1b[0m` +const BLUE = (s: string) => `\x1b[34m${s}\x1b[0m` +const GREEN = (s: string) => `\x1b[32m${s}\x1b[0m` +const RED = (s: string) => `\x1b[31m${s}\x1b[0m` +const YELLOW = (s: string) => `\x1b[33m${s}\x1b[0m` + +// --- Judge ---------------------------------------------------------------- + +interface Verdict { + ok: boolean + reason: string +} + +function buildTranscript(events: ReadonlyArray): string { + const lines: string[] = [] + for (const e of events) { + if (e.type === "user.message") { + lines.push(`[user] ${typeof e.content === "string" ? e.content : JSON.stringify(e.content)}`) + } else if (e.type === "assistant.message") { + if (e.content) lines.push(`[assistant] ${e.content}`) + } else if (e.type === "tool.call.started") { + lines.push(`[tool.call] ${e.tool_name}`) + } else if (e.type === "tool.result") { + lines.push(`[tool.result] ${e.content.slice(0, 400)}`) + } + } + return lines.join("\n") +} + +async function judge( + infer: InferFn, + condition: string, + transcript: string, +): Promise { + const system = [ + "You are evaluating a hook condition in agentjsx.", + "Judge whether the user-provided condition is met against the transcript below.", + 'Respond with a JSON object EXACTLY of the shape {"ok": boolean, "reason": string}.', + "Always include a reason. Quote specific text from the transcript when possible.", + "If there is no clear evidence, return ok: false with reason \"insufficient evidence\".", + ].join("\n") + + const userMsg = `CONDITION:\n${condition}\n\nTRANSCRIPT:\n${transcript}\n\nRespond with JSON only.` + + const res = await infer({ + system, + messages: [{ role: "user", content: userMsg }], + tools: [], + }) + + const raw = res.content.trim() + // Strip ```json fences if the model adds them + const cleaned = raw.replace(/^```(?:json)?\s*/i, "").replace(/```$/i, "").trim() + try { + const parsed = JSON.parse(cleaned) + if (typeof parsed?.ok === "boolean" && typeof parsed?.reason === "string") { + return parsed + } + return { ok: false, reason: `judge returned malformed JSON: ${raw}` } + } catch { + return { ok: false, reason: `judge returned non-JSON: ${raw}` } + } +} + +// --- Agent loop ----------------------------------------------------------- + +async function drainTurn(agent: ReturnType): Promise { + const startLen = (await agent.events()).length + let printed = startLen + while (true) { + await new Promise((r) => setTimeout(r, 100)) + const events = await agent.events() + for (let i = printed; i < events.length; i++) { + const e = events[i]! + if (e.type === "assistant.message" && e.content) { + console.log(`${GREEN("agent")} ${e.content}`) + } else if (e.type === "tool.call.started") { + console.log(DIM(` ${YELLOW("→")} ${e.tool_name}`)) + } else if (e.type === "tool.result") { + const snippet = e.content.length > 80 ? `${e.content.slice(0, 80)}…` : e.content + console.log(DIM(` ${YELLOW("←")} ${snippet}`)) + } else if (e.type === "assistant.halted") { + console.log(DIM(` ${YELLOW("!")} halted: ${e.reason}`)) + } + } + printed = events.length + const last = events[events.length - 1] + const isTerminal = + (last?.type === "assistant.message" && !last.tool_calls?.length) || + last?.type === "assistant.halted" || + last?.type === "inference.failed" + if (isTerminal) break + } +} + +async function main(): Promise { + const apiKey = process.env.AI_GATEWAY_API_KEY + if (!apiKey) { + console.error("Set AI_GATEWAY_API_KEY (or run under `infisical run --silent`).") + process.exit(1) + } + + const infer = createAiGatewayInfer({ + apiKey, + model: "anthropic/claude-sonnet-4-6", + }) + + const agent = createAgentRuntime({ + infer, + platform: NodeContext.layer, + context: () => + render( + + + You are a friendly assistant. Respond briefly to the user. + + + + , + ), + }) + + try { + console.log(DIM(`goal: ${GOAL}`)) + console.log("") + console.log(`${BLUE("you")} ${INITIAL_PROMPT}`) + await agent.run(INITIAL_PROMPT) + await drainTurn(agent) + + for (let i = 1; i <= MAX_ITERATIONS; i++) { + const events = await agent.events() + const transcript = buildTranscript(events) + console.log("") + console.log(DIM(` judging iteration ${i}/${MAX_ITERATIONS}…`)) + const verdict = await judge(infer, GOAL, transcript) + if (verdict.ok) { + console.log(GREEN(` ✓ goal met: ${verdict.reason}`)) + return + } + console.log(RED(` ✗ not met: ${verdict.reason}`)) + const reprompt = `[goal: ${GOAL}]: ${verdict.reason}` + console.log(`${BLUE("you")} ${reprompt}`) + await agent.run(reprompt) + await drainTurn(agent) + } + console.log("") + console.log(RED(`exhausted ${MAX_ITERATIONS} iterations without meeting goal`)) + process.exit(2) + } finally { + await agent.dispose() + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/examples/goal-test/package.json b/examples/goal-test/package.json new file mode 100644 index 0000000..e5e8020 --- /dev/null +++ b/examples/goal-test/package.json @@ -0,0 +1,20 @@ +{ + "name": "agentjsx-example-goal-test", + "private": true, + "version": "0.0.0", + "description": "Userspace emulation of Claude Code's /goal: re-prompt the agent until an independent judge says the condition is met.", + "type": "module", + "scripts": { + "start": "tsx cli.tsx" + }, + "dependencies": { + "@effect/platform-node": "^0.106.0", + "@flamecast/agentjsx": "file:../..", + "effect": "^3.21.1" + }, + "devDependencies": { + "@types/node": "^22.19.17", + "tsx": "^4.20.0", + "typescript": "^5.8.3" + } +} diff --git a/examples/goal-test/tsconfig.json b/examples/goal-test/tsconfig.json new file mode 100644 index 0000000..469d96e --- /dev/null +++ b/examples/goal-test/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "react-jsx", + "jsxImportSource": "@flamecast/agentjsx", + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["cli.tsx"] +} diff --git a/src/jsx/components/index.ts b/src/jsx/components/index.ts index 62848f1..8e80f93 100644 --- a/src/jsx/components/index.ts +++ b/src/jsx/components/index.ts @@ -9,3 +9,5 @@ export { Skills } from "./skills"; export { Compact } from "./compact"; export { McpServer } from "./mcp"; export { Subagent } from "./subagent"; +export { Memory } from "./memory"; +export { WebSearch, WebFetch } from "./web"; diff --git a/src/jsx/components/memory.tsx b/src/jsx/components/memory.tsx new file mode 100644 index 0000000..d03914d --- /dev/null +++ b/src/jsx/components/memory.tsx @@ -0,0 +1,198 @@ +// Capability component — persistent cross-conversation memory backed by +// a directory on disk. Three tools (memory_read / memory_write / +// memory_list) plus a one-line ambient block telling the model where +// memory lives and how to use it. +// +// Design note: this is a thin filesystem capability. There is no +// auto-loading of a top-level index file at render time (that would +// require the MCP-style async cache pattern); the model is expected to +// call `memory_list` (or `memory_read` of a known index) on its first +// turn if it wants to discover existing memory. Operators who want +// always-loaded memory can compose with `` and +// pre-read the index themselves. +// +// Path safety: tool args are resolved relative to `root` and rejected if +// they escape via `..`. The check is string-prefix on the resolved +// path; the platform layer does not currently expose `realpath`. + +import { FileSystem, Path } from "@effect/platform"; +import { Effect, Schema } from "effect"; +import { defineTool } from "../../core/define-tool"; +import type { Fragment as RenderedFragment } from "../../core/types"; +import { emitFragment, emitTool, type Element, type Node } from "../runtime"; +import { useRenderContext } from "../render"; + +export interface MemoryProps { + // Directory where memory files live. Created on first write if absent. + readonly root: string; +} + +// Resolve `path` against `root` and reject escapes. Returns the absolute +// target, or `null` if the path is outside the memory root. Uses string +// prefix on the resolved path; the platform layer does not currently +// expose realpath. +function safeResolve( + resolve: (...parts: string[]) => string, + sep: string, + root: string, + path: string, +): string | null { + const absRoot = resolve(root); + const target = resolve(absRoot, path); + if (target !== absRoot && !target.startsWith(absRoot + sep)) { + return null; + } + return target; +} + +export function Memory(props: MemoryProps): Node { + const { root } = props; + const { runEffect } = useRenderContext(); + + const memory_read = defineTool({ + name: "memory_read", + description: + "Read a memory file by path (relative to the memory root). Returns the file contents.", + parameters: Schema.Struct({ + path: Schema.String, + }), + run: async ({ path }) => { + try { + return await runEffect( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const p = yield* Path.Path; + const target = safeResolve( + (...parts: string[]) => p.resolve(...parts), + p.sep, + root, + path, + ); + if (target === null) { + return `Error: path "${path}" escapes the memory root.`; + } + const exists = yield* fs.exists(target); + if (!exists) return `Memory not found: ${path}`; + return yield* fs.readFileString(target); + }).pipe( + Effect.catchAll((e) => + Effect.succeed( + `[memory_read] Error: ${e instanceof Error ? e.message : String(e)}`, + ), + ), + ) as unknown as Effect.Effect, + ); + } catch (e) { + return `[memory_read] Error: ${e instanceof Error ? e.message : String(e)}`; + } + }, + }); + + const memory_write = defineTool({ + name: "memory_write", + description: + "Write a memory file (relative to the memory root). Creates parent directories as needed. Overwrites if the file exists.", + parameters: Schema.Struct({ + path: Schema.String, + contents: Schema.String, + }), + run: async ({ path, contents }) => { + try { + return await runEffect( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const p = yield* Path.Path; + const target = safeResolve( + (...parts: string[]) => p.resolve(...parts), + p.sep, + root, + path, + ); + if (target === null) { + return `Error: path "${path}" escapes the memory root.`; + } + const dir = p.dirname(target); + yield* fs.makeDirectory(dir, { recursive: true }); + yield* fs.writeFileString(target, contents); + return `Wrote ${contents.length} chars to ${path}`; + }).pipe( + Effect.catchAll((e) => + Effect.succeed( + `[memory_write] Error: ${e instanceof Error ? e.message : String(e)}`, + ), + ), + ) as unknown as Effect.Effect, + ); + } catch (e) { + return `[memory_write] Error: ${e instanceof Error ? e.message : String(e)}`; + } + }, + }); + + const memory_list = defineTool({ + name: "memory_list", + description: + "Recursively list memory files. Returns one path per line, relative to the memory root.", + parameters: Schema.Struct({}), + run: async () => { + try { + return await runEffect( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const p = yield* Path.Path; + const absRoot = p.resolve(root); + const exists = yield* fs.exists(absRoot); + if (!exists) return "(memory directory does not exist yet)"; + const out: string[] = []; + const walkDir = ( + dir: string, + ): Effect.Effect => + Effect.gen(function* () { + const entries = yield* fs.readDirectory(dir); + for (const name of [...entries].sort()) { + const full = p.resolve(dir, name); + const stat = yield* fs.stat(full).pipe( + Effect.catchAll(() => + Effect.succeed({ type: "File" as const }), + ), + ); + if (stat.type === "Directory") { + yield* walkDir(full); + } else { + out.push(p.relative(absRoot, full)); + } + } + }); + yield* walkDir(absRoot); + return out.length === 0 ? "(empty)" : out.join("\n"); + }).pipe( + Effect.catchAll((e) => + Effect.succeed( + `[memory_list] Error: ${e instanceof Error ? e.message : String(e)}`, + ), + ), + ) as unknown as Effect.Effect, + ); + } catch (e) { + return `[memory_list] Error: ${e instanceof Error ? e.message : String(e)}`; + } + }, + }); + + const block: RenderedFragment = { + tag: "core/system", + content: + `\n` + + " Persistent across conversations. Use memory_list to discover, memory_read to load, memory_write to save.\n" + + "", + source: "memory", + }; + + const emits: Element[] = [ + emitTool(memory_read), + emitTool(memory_write), + emitTool(memory_list), + emitFragment(block), + ]; + return emits as Node; +} diff --git a/src/jsx/components/web.tsx b/src/jsx/components/web.tsx new file mode 100644 index 0000000..d7ac801 --- /dev/null +++ b/src/jsx/components/web.tsx @@ -0,0 +1,172 @@ +// Capability components for the public web. Two tools, no platform +// dependency beyond a global `fetch` — works in Node 18+, Bun, browsers, +// Cloudflare Workers. +// +// Kept separate from the projection-time `web-search` extension at +// `src/extensions/web-search.ts`. The extension lives at the runtime +// layer and runs whether or not a JSX tree mounts it; these components +// live at render time and only contribute tools when the JSX tree +// includes them. Same Exa API call shape; deliberate duplication to +// keep the JSX path free of Effect/Layer plumbing. + +import { Schema } from "effect"; +import { defineTool } from "../../core/define-tool"; +import type { Fragment as RenderedFragment } from "../../core/types"; +import { emitFragment, emitTool, type Element, type Node } from "../runtime"; + +// --------------------------------------------------------------------- +// +// +// Exposes `web_search(query, numResults?)`. Backed by Exa. +// --------------------------------------------------------------------- + +export interface WebSearchProps { + readonly apiKey: string; + readonly defaultNumResults?: number; + readonly snippetChars?: number; +} + +interface ExaResult { + title: string; + url: string; + text?: string; + publishedDate?: string; +} + +const isRecord = (v: unknown): v is Record => + typeof v === "object" && v !== null; + +const isExaResult = (v: unknown): v is ExaResult => + isRecord(v) && typeof v.title === "string" && typeof v.url === "string"; + +export function WebSearch(props: WebSearchProps): Node { + const { apiKey, defaultNumResults = 5, snippetChars = 400 } = props; + + const web_search = defineTool({ + name: "web_search", + description: + "Search the web via Exa. Returns top matches as {title, url, snippet}. Use for current or world-knowledge information the model wasn't trained on.", + parameters: Schema.Struct({ + query: Schema.String.annotations({ + description: "Natural language query. Be specific.", + }), + numResults: Schema.Number.annotations({ + description: `Maximum results. Default ${defaultNumResults}.`, + }).pipe(Schema.optionalWith({ nullable: true })), + }), + run: async ({ query, numResults }) => { + if (!apiKey) return "Error: WebSearch apiKey is empty."; + const q = query.trim(); + if (!q) return "Error: query is required."; + const n = + numResults !== undefined + ? Math.max(1, Math.min(20, Math.floor(numResults))) + : defaultNumResults; + + try { + const res = await fetch("https://api.exa.ai/search", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ + query: q, + numResults: n, + type: "auto", + contents: { text: { maxCharacters: snippetChars } }, + }), + }); + if (!res.ok) { + const text = await res.text(); + return `Error: Exa ${res.status}: ${text.slice(0, 500)}`; + } + const raw: unknown = await res.json(); + const results = + isRecord(raw) && Array.isArray(raw.results) + ? raw.results.filter(isExaResult).map((r) => ({ + title: r.title, + url: r.url, + publishedDate: r.publishedDate, + snippet: + typeof r.text === "string" + ? r.text.replace(/\s+/g, " ").slice(0, snippetChars) + : "", + })) + : []; + if (results.length === 0) return `No results for "${q}".`; + return JSON.stringify(results, null, 2); + } catch (e) { + return `[web_search] Error: ${e instanceof Error ? e.message : String(e)}`; + } + }, + }); + + const block: RenderedFragment = { + tag: "core/system", + content: "(call `web_search` to query Exa)", + source: "web-search", + }; + + const emits: Element[] = [emitTool(web_search), emitFragment(block)]; + return emits as Node; +} + +// --------------------------------------------------------------------- +// +// +// Exposes `web_fetch(url)`. GETs a URL and returns the body, truncated +// to `maxChars`. No HTML stripping — the model is trusted to read raw +// markup; a "readability" mode is a follow-up. +// --------------------------------------------------------------------- + +export interface WebFetchProps { + // Max characters of body returned. Default 20000. Longer responses are + // sliced; a `[truncated]` suffix is appended. + readonly maxChars?: number; + // Optional fixed headers for every request (e.g. `User-Agent`). + readonly headers?: Record; +} + +export function WebFetch(props: WebFetchProps = {}): Node { + const { maxChars = 20000, headers } = props; + + const web_fetch = defineTool({ + name: "web_fetch", + description: + "GET a URL and return the response body as text. Body is truncated past a character budget. Use for fetching docs, READMEs, or specific pages you already have a URL for.", + parameters: Schema.Struct({ + url: Schema.String.annotations({ + description: "Absolute http(s) URL to fetch.", + }), + }), + run: async ({ url }) => { + const target = url.trim(); + if (!/^https?:\/\//i.test(target)) { + return `Error: web_fetch only accepts http(s) URLs; got "${target}".`; + } + try { + const res = await fetch(target, { headers }); + const contentType = res.headers.get("content-type") ?? ""; + const text = await res.text(); + const sliced = + text.length > maxChars + ? `${text.slice(0, maxChars)}\n[truncated: ${text.length} chars total]` + : text; + const status = `${res.status} ${res.statusText}`.trim(); + return `[${status}] ${contentType}\n\n${sliced}`; + } catch (e) { + return `[web_fetch] Error: ${e instanceof Error ? e.message : String(e)}`; + } + }, + }); + + const block: RenderedFragment = { + tag: "core/system", + content: "(call `web_fetch` with an http(s) URL)", + source: "web-fetch", + }; + + const emits: Element[] = [emitTool(web_fetch), emitFragment(block)]; + return emits as Node; +} diff --git a/web/src/HeroCodeBlock.tsx b/web/src/HeroCodeBlock.tsx index e3b4920..d8dd2de 100644 --- a/web/src/HeroCodeBlock.tsx +++ b/web/src/HeroCodeBlock.tsx @@ -44,10 +44,9 @@ export function HeroCodeBlock({
{filename ? ( - - - - + + + TS {filename} diff --git a/web/src/InteractiveContextHero.tsx b/web/src/InteractiveContextHero.tsx index a09170e..ebdc7d2 100644 --- a/web/src/InteractiveContextHero.tsx +++ b/web/src/InteractiveContextHero.tsx @@ -5,8 +5,8 @@ import { HeroCodeBlock } from "./HeroCodeBlock" export const CODE = `import { createAgentRuntime, createAiGatewayInfer, render } from "@flamecast/agentjsx" import { Agent, Block, Messages, - Workspace, Skills, McpServer, Todo, - Compact, + Workspace, Skills, McpServer, Memory, WebSearch, WebFetch, + Todo, Subagent, Compact, } from "@flamecast/agentjsx/components" import { NodeContext } from "@flamecast/agentjsx/node" @@ -18,13 +18,15 @@ const agent = createAgentRuntime({ You are a coding assistant. + + + - + + + + @@ -45,14 +47,10 @@ const LINE_SLICE: Record = { 13: "role", // 14: "fs", // 15: "skill", // - 16: "mcp", // - 17: "mcp", // - 24: "messages", // - 30: "messages", // await agent.run("...") + 19: "mcp", // + 20: "mcp", // + 26: "messages", // + 32: "messages", // await agent.run("...") } const HIGHLIGHTED = new Set(Object.keys(LINE_SLICE).map(Number)) @@ -145,10 +143,9 @@ export function InteractiveContextHero() {
- - - - + + + TS agent.tsx diff --git a/web/src/Landing.tsx b/web/src/Landing.tsx index d66cfbd..d5c821a 100644 --- a/web/src/Landing.tsx +++ b/web/src/Landing.tsx @@ -1,6 +1,53 @@ import { useCallback, useState } from "react" import { HeroCodeBlock } from "./HeroCodeBlock" import { CODE } from "./InteractiveContextHero" +import agentjsxSkill from "../../skills/agentjsx/SKILL.md?raw" + +const COPY_PROMPT = `I want to build a coding agent using @flamecast/agentjsx. Read the skill below to understand the library, then scaffold a minimal agent for me. Ask me what tools and capabilities I want before writing code. + +--- + +${agentjsxSkill}` + +const COMPONENT_CODE = `import { render } from "@flamecast/agentjsx" +import { + Agent, Messages, emitFragment, emitHaltPredicate, +} from "@flamecast/agentjsx/components" + +// Claude-Code-style /goal. The agent can't call a "done" tool — it +// doesn't have one. Halting is gated by an independent inference call +// that judges the transcript against the condition. The model sees +// the goal in its system block, but completion is decided externally. +function Goal({ condition }: { condition: string }) { + return [ + emitFragment({ + tag: "core/system", + source: "goal", + content: \`\${condition}\\n(stopping is blocked until this holds)\`, + }), + emitHaltPredicate(async ({ events, infer }) => { + const transcript = events + .filter(e => e.type === "user.message" || e.type === "assistant.message") + .map(e => \`[\${e.type}] \${"content" in e ? e.content : ""}\`) + .join("\\n") + const res = await infer({ + system: \`Judge whether this condition holds: "\${condition}". + Reply with JSON {"ok": boolean, "reason": string}.\`, + messages: [{ role: "user", content: transcript }], + tools: [], + }) + return JSON.parse(res.content) + }), + ] +} + +// Drop it in like any built-in component: +render( + + + + +)` function LandingHeader() { return ( @@ -8,17 +55,14 @@ function LandingHeader() { agentctx - - Docs - ) } -function CopySnippetCta() { +function CopyPromptCta() { const [copied, setCopied] = useState(false) const handleCopy = useCallback(() => { - navigator.clipboard.writeText(CODE).then(() => { + navigator.clipboard.writeText(COPY_PROMPT).then(() => { setCopied(true) setTimeout(() => setCopied(false), 2000) }) @@ -26,7 +70,7 @@ function CopySnippetCta() { return ( ) } @@ -37,16 +81,6 @@ const LINKS = [ label: "GitHub", desc: "Source, issues, and the full extension catalog.", }, - { - href: "https://www.npmjs.com/package/@flamecast/agentjsx", - label: "npm", - desc: "Install with bun add @flamecast/agentjsx.", - }, - { - href: "https://github.com/smithery-ai/agentjsx/tree/main/examples", - label: "Examples", - desc: "Local coding agent and Cloudflare Sandbox variants.", - }, ] export function Landing() { @@ -56,16 +90,18 @@ export function Landing() {
-

Render your agent's context like a UI

+

+ Write your own Claude Code. +
+ Run it anywhere. +

- Event log to JSX to context. agentctx is an - Effect-based agent harness that treats LLM context - like React treats the DOM: composable steering - extensions shape what your model sees and does, and - the same code runs anywhere V8 does. + A runtime agnostic agent harness framework. Compose + your agent from reusable JSX components, then run it + anywhere V8 does: Node, Bun, or a browser tab.

- +
+
+
+

+ Build your own components. +

+

+ Components are functions that contribute tools, + prompt content, or both. Some wrap others to + reshape what they produce. Drop them inside{" "} + {""}; the runtime handles + diffing and tool reconciliation between renders. +

+
+
+ +
+
+
diff --git a/web/vite.config.ts b/web/vite.config.ts index d0262f7..b81a217 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -5,4 +5,9 @@ import tailwindcss from "@tailwindcss/vite" export default defineConfig({ plugins: [react(), tailwindcss()], build: { outDir: "dist" }, + server: { + fs: { + allow: [".."], + }, + }, })