diff --git a/AGENTS.md b/AGENTS.md index 7625c2f86..b04484617 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,4 +59,5 @@ Repo-level test apps live under `tests/`: - **Verification**: Run format, lint, typecheck, build, test before committing - **Main branch**: NEVER commit directly - **Documentation**: Don't create docs unless explicitly asked +- **Docs examples**: Treat external framework memory as stale. Before shipping examples for Next, Nuxt, SvelteKit, Drizzle, Better Auth, Vite, or similar tools, check current upstream docs and align with their recommended patterns. Verify the Agentuity integration locally, but also verify that the surrounding framework usage is idiomatic. Working code is not enough if the setup is not how the framework expects users to do it. - **Clarification**: Ask before major code changes if unsure diff --git a/bun.lock b/bun.lock index 3789b2b55..4183b2282 100644 --- a/bun.lock +++ b/bun.lock @@ -44,10 +44,11 @@ "@agentuity/schedule": "workspace:*", "@agentuity/schema": "workspace:*", "@agentuity/server": "workspace:*", - "@ai-sdk/anthropic": "^2.0.56", - "@ai-sdk/google": "^2.0.51", - "@ai-sdk/groq": "^2.0.33", - "@ai-sdk/openai": "^2.0.88", + "@ai-sdk/anthropic": "^3.0.72", + "@ai-sdk/google": "^3.0.65", + "@ai-sdk/groq": "^3.0.36", + "@ai-sdk/openai": "^3.0.54", + "@anthropic-ai/sdk": "^0.91.1", "@heroicons/react": "^2.2.0", "@mdx-js/react": "^3.1.0", "@mdx-js/rollup": "^3.1.0", @@ -60,7 +61,7 @@ "@tailwindcss/typography": "^0.5.19", "@tanstack/react-router": "^1.157.18", "@xyflow/react": "^12.6.0", - "ai": "^5.0.116", + "ai": "^6.0.170", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -837,15 +838,15 @@ "@agentuity/webhook": ["@agentuity/webhook@workspace:packages/webhook"], - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.77", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8n7ApEzFOxqVvT3HyqLrEQlgUx/2nUmPFLTGY3fNKwUA8KVNU3Ovd2C66Qh1Y93Iq5NkHsOWuLiTyAZpRKQhgw=="], + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.72", "", { "dependencies": { "@ai-sdk/provider": "3.0.9", "@ai-sdk/provider-utils": "4.0.24" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-t0j9mggxylA9uP0hi12NlRk2npYh4QkE7JIpws2MdV/18QzHcsT6+TNGIjbOPayLQDjrmRKx78Ym7iZkg9qRxQ=="], "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="], - "@ai-sdk/google": ["@ai-sdk/google@2.0.70", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NDMTvMo6vnPHDTA94FBOh3YMv0lxWDohYmFSGYhg0IimHMcOcC1ZV7E2KMLjzHOz5S7uasTITW7V3X5T+ozInQ=="], + "@ai-sdk/google": ["@ai-sdk/google@3.0.65", "", { "dependencies": { "@ai-sdk/provider": "3.0.9", "@ai-sdk/provider-utils": "4.0.24" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SwdaJ6IqguyiVuDRgiRM4sHj7uUO4AETlQFFLF3jcEvu/3yrgIHfw2aM6bBNKSdalw0j25Pedx6qyHc2DWJwrg=="], - "@ai-sdk/groq": ["@ai-sdk/groq@2.0.38", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qw6OB9RTYy1JO1//FK/bE4o12sl6s6uff7+C2L0AlskRbqOw0oWNh1FjeQU9bqHdObL5P6/UU1Cnq0FcQ7OT3g=="], + "@ai-sdk/groq": ["@ai-sdk/groq@3.0.36", "", { "dependencies": { "@ai-sdk/provider": "3.0.9", "@ai-sdk/provider-utils": "4.0.24" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-77onMo3RZg6wG9qZQuekqS18YDS1znRZNN6PuBOsSm/TjryttUq4VOhk1HXogc3QWoEaTYGFxjZgZGp3wTJuSg=="], - "@ai-sdk/openai": ["@ai-sdk/openai@2.0.103", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FDwY060LV/D5th+LeaxpSKcot5eXjzNzHguDf0NU1K+v7rxYZFWbldQPZarNo/IpD/WJE9RojgrFAcZ1e8KyvQ=="], + "@ai-sdk/openai": ["@ai-sdk/openai@3.0.54", "", { "dependencies": { "@ai-sdk/provider": "3.0.9", "@ai-sdk/provider-utils": "4.0.24" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-j1qrNe/ebUKuE+fETzS+CVnczs11jQBR9y9M6aoKtJZAosg6SZnPC1Bb92e2u6yaSK+88TZoFhiY67uYphPitw=="], "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], @@ -853,7 +854,7 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.90.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], @@ -3903,21 +3904,21 @@ "@agentuity/telemetry/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], + "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@3.0.9", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-/ngMKqKdL9dSlY/eQ3NFDzzFyw0Hix+cbFFlyuKEKcOgpHdBt/spKUvX/i0wGrDLFPYJeVvv3N0j92LxWRL7yQ=="], - "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-60GYsRj5wIJQRcq5YwYJq4KhwLeStceXEJiZdecP1miiH+6FMmrnc7lZDOJoQ6m9lrudEb+uI4LEwddLz5+rPQ=="], + "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.24", "", { "dependencies": { "@ai-sdk/provider": "3.0.9", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oXIw1oLmuBILuvHgSj6w5LOV8oSnFRouPSv0MGkG9sRMeukZ9JnMF17kldaRQaRq8lSJIxo6aS3NzWlVmSb+4Q=="], - "@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], + "@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.9", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-/ngMKqKdL9dSlY/eQ3NFDzzFyw0Hix+cbFFlyuKEKcOgpHdBt/spKUvX/i0wGrDLFPYJeVvv3N0j92LxWRL7yQ=="], - "@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-60GYsRj5wIJQRcq5YwYJq4KhwLeStceXEJiZdecP1miiH+6FMmrnc7lZDOJoQ6m9lrudEb+uI4LEwddLz5+rPQ=="], + "@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.24", "", { "dependencies": { "@ai-sdk/provider": "3.0.9", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oXIw1oLmuBILuvHgSj6w5LOV8oSnFRouPSv0MGkG9sRMeukZ9JnMF17kldaRQaRq8lSJIxo6aS3NzWlVmSb+4Q=="], - "@ai-sdk/groq/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], + "@ai-sdk/groq/@ai-sdk/provider": ["@ai-sdk/provider@3.0.9", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-/ngMKqKdL9dSlY/eQ3NFDzzFyw0Hix+cbFFlyuKEKcOgpHdBt/spKUvX/i0wGrDLFPYJeVvv3N0j92LxWRL7yQ=="], - "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-60GYsRj5wIJQRcq5YwYJq4KhwLeStceXEJiZdecP1miiH+6FMmrnc7lZDOJoQ6m9lrudEb+uI4LEwddLz5+rPQ=="], + "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.24", "", { "dependencies": { "@ai-sdk/provider": "3.0.9", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oXIw1oLmuBILuvHgSj6w5LOV8oSnFRouPSv0MGkG9sRMeukZ9JnMF17kldaRQaRq8lSJIxo6aS3NzWlVmSb+4Q=="], - "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], + "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.9", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-/ngMKqKdL9dSlY/eQ3NFDzzFyw0Hix+cbFFlyuKEKcOgpHdBt/spKUvX/i0wGrDLFPYJeVvv3N0j92LxWRL7yQ=="], - "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-60GYsRj5wIJQRcq5YwYJq4KhwLeStceXEJiZdecP1miiH+6FMmrnc7lZDOJoQ6m9lrudEb+uI4LEwddLz5+rPQ=="], + "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.24", "", { "dependencies": { "@ai-sdk/provider": "3.0.9", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oXIw1oLmuBILuvHgSj6w5LOV8oSnFRouPSv0MGkG9sRMeukZ9JnMF17kldaRQaRq8lSJIxo6aS3NzWlVmSb+4Q=="], "@antfu/install-pkg/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], @@ -3943,6 +3944,8 @@ "@langchain/openai/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@mariozechner/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.90.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg=="], + "@mariozechner/pi-ai/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "@mariozechner/pi-ai/openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], @@ -4137,7 +4140,7 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], - "docs/ai": ["ai@5.0.179", "", { "dependencies": { "@ai-sdk/gateway": "2.0.82", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tuq/r2FH/pBuY3jo0yHF3UglDV73WONGLhW80DuwgO6w0ftPIqRsAm5p9cE3Bu4LfEuCkMXpiUG/pQRzqKRRaA=="], + "docs/ai": ["ai@6.0.170", "", { "dependencies": { "@ai-sdk/gateway": "3.0.105", "@ai-sdk/provider": "3.0.9", "@ai-sdk/provider-utils": "4.0.24", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FWTKeGGDRcYJtPWIrdZDSuvOW5LCjI2NZUJmaml8OTOaPEsXnFdFvmawCXbT+wTGxyWKJTgZ9sZtCjbJsmjM2Q=="], "docs/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -4325,11 +4328,11 @@ "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], - "docs/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.82", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-vtoCSEBGPcxzChI3eqe9C9AJSlc/WUZp92tzpOqVd4B6Tnu4583S+qR7TknB0tPta15TEoOIkK0ENW6D/DgRJQ=="], + "docs/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.105", "", { "dependencies": { "@ai-sdk/provider": "3.0.9", "@ai-sdk/provider-utils": "4.0.24", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XpERadvLMHkYGJO4hz1Sw7Y9J705Iex48TmdZLdZdaPiFFtVgiA9qhXugLUWGAxdlXU/2N6ipoPSOwfnULZbXw=="], - "docs/ai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], + "docs/ai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.9", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-/ngMKqKdL9dSlY/eQ3NFDzzFyw0Hix+cbFFlyuKEKcOgpHdBt/spKUvX/i0wGrDLFPYJeVvv3N0j92LxWRL7yQ=="], - "docs/ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-60GYsRj5wIJQRcq5YwYJq4KhwLeStceXEJiZdecP1miiH+6FMmrnc7lZDOJoQ6m9lrudEb+uI4LEwddLz5+rPQ=="], + "docs/ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.24", "", { "dependencies": { "@ai-sdk/provider": "3.0.9", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oXIw1oLmuBILuvHgSj6w5LOV8oSnFRouPSv0MGkG9sRMeukZ9JnMF17kldaRQaRq8lSJIxo6aS3NzWlVmSb+4Q=="], "docs/ai/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], @@ -4439,8 +4442,6 @@ "archiver-utils/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "docs/ai/@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], - "gcp-metadata/gaxios/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/docs/app.ts b/docs/app.ts index fe40274b0..5bbb6ad38 100644 --- a/docs/app.ts +++ b/docs/app.ts @@ -7,7 +7,7 @@ import { docRedirectRules, getDemoRedirectTarget } from './src/web/lib/docs-redi const redirects = new Hono(); // Permanent server-side redirects for legacy docs URLs -// Matching TanStack routes handle the same redirects during client navigation +// Some matching TanStack routes handle redirects during client navigation for (const rule of docRedirectRules) { for (const path of rule.paths) { redirects.get(path, (c) => c.redirect(rule.target, 301)); diff --git a/docs/package.json b/docs/package.json index a16f529e6..6550b1b27 100644 --- a/docs/package.json +++ b/docs/package.json @@ -3,16 +3,19 @@ "version": "3.0.0-alpha.6", "license": "Apache-2.0", "private": true, - "module": ".agentuity/app.js", + "module": "dist/server.js", "type": "module", "scripts": { "predev": "bun run scripts/generate-api-reference.ts && bun run scripts/generate-nav-data.ts && bun run scripts/validate-routes.ts && bun run scripts/generate-markdown-files.ts", "prebuild": "bun run scripts/generate-api-reference.ts && bun run scripts/generate-nav-data.ts && bun run scripts/validate-routes.ts && bun run scripts/generate-markdown-files.ts", "predeploy": "cd ../ && bun install && bun run build:packages && cd docs && bun run prebuild", - "build": "bun ../../packages/cli/bin/cli.ts build --dir . --dev", - "dev": "bun ../../packages/cli/bin/cli.ts dev --dir .", - "start": "bun .agentuity/app.js", - "deploy": "bun ../../packages/cli/bin/cli.ts deploy --dir .", + "build": "bun run build:app", + "build:app": "vite build && bun build server.ts --target=bun --outfile=dist/server.js", + "dev": "vite dev --host 0.0.0.0", + "start": "NODE_ENV=production bun dist/server.js", + "agentuity:build": "bun ../packages/cli/bin/cli.ts build --dir . --dev", + "agentuity:dev": "bun ../packages/cli/bin/cli.ts dev --dir .", + "deploy": "bun ../packages/cli/bin/cli.ts deploy --dir .", "typecheck": "bunx tsc --noEmit", "build:run": "bun run scripts/bundle-run-scripts.ts", "generate:scripts": "bun run scripts/generate-sandbox-scripts.ts", @@ -25,13 +28,13 @@ "@agentuity/schedule": "workspace:*", "@agentuity/schema": "workspace:*", "@agentuity/server": "workspace:*", - "@ai-sdk/anthropic": "^2.0.56", - "@ai-sdk/google": "^2.0.51", - "@ai-sdk/groq": "^2.0.33", - "@ai-sdk/openai": "^2.0.88", + "@ai-sdk/anthropic": "^3.0.72", + "@ai-sdk/google": "^3.0.65", + "@ai-sdk/groq": "^3.0.36", + "@ai-sdk/openai": "^3.0.54", + "@anthropic-ai/sdk": "^0.91.1", "@heroicons/react": "^2.2.0", "@mdx-js/react": "^3.1.0", - "@xyflow/react": "^12.6.0", "@mdx-js/rollup": "^3.1.0", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-aspect-ratio": "^1.1.8", @@ -41,11 +44,12 @@ "@stefanprobst/rehype-extract-toc": "^3.0.0", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-router": "^1.157.18", - "ai": "^5.0.116", + "@xyflow/react": "^12.6.0", + "ai": "^6.0.170", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "gray-matter": "^4.0.3", "cmdk": "^1.1.1", + "gray-matter": "^4.0.3", "hono": "^4.11.3", "langchain": "^0.3.0", "lucide-react": "^0.548.0", diff --git a/docs/server.ts b/docs/server.ts new file mode 100644 index 000000000..c53fa3f92 --- /dev/null +++ b/docs/server.ts @@ -0,0 +1,82 @@ +import { join, normalize } from 'node:path'; +import { docRedirectRules, getDemoRedirectTarget } from './src/web/lib/docs-redirects'; + +const PORT = Number.parseInt(process.env.PORT ?? '3000', 10); +const INDEX_FILE = join(import.meta.dir, 'src/web/index.html'); + +function redirect(location: string): Response { + return new Response(null, { + status: 301, + headers: { location }, + }); +} + +function matchesPath(paths: readonly string[], pathname: string): boolean { + return paths.includes(pathname); +} + +function contentType(pathname: string): string | undefined { + if (pathname.endsWith('.html')) return 'text/html; charset=utf-8'; + if (pathname.endsWith('.js')) return 'text/javascript; charset=utf-8'; + if (pathname.endsWith('.css')) return 'text/css; charset=utf-8'; + if (pathname.endsWith('.json')) return 'application/json; charset=utf-8'; + if (pathname.endsWith('.txt')) return 'text/plain; charset=utf-8'; + if (pathname.endsWith('.xml')) return 'application/xml; charset=utf-8'; + if (pathname.endsWith('.ico')) return 'image/x-icon'; + if (pathname.endsWith('.svg')) return 'image/svg+xml'; + if (pathname.endsWith('.png')) return 'image/png'; + if (pathname.endsWith('.webp')) return 'image/webp'; + if (pathname.endsWith('.woff')) return 'font/woff'; + if (pathname.endsWith('.woff2')) return 'font/woff2'; + return undefined; +} + +async function staticFile(pathname: string): Promise { + const normalized = normalize(decodeURIComponent(pathname)).replace(/^(\.\.(\/|\\|$))+/, ''); + const file = Bun.file(join(import.meta.dir, normalized)); + + if (!(await file.exists())) { + return null; + } + + const type = contentType(pathname); + + return new Response(file, { + headers: type ? { 'content-type': type } : undefined, + }); +} + +Bun.serve({ + port: PORT, + hostname: '0.0.0.0', + async fetch(request) { + const url = new URL(request.url); + + for (const rule of docRedirectRules) { + if (matchesPath(rule.paths, url.pathname)) { + return redirect(rule.target); + } + } + + if (url.pathname.startsWith('/demo/')) { + return redirect(getDemoRedirectTarget(url.pathname.replace(/^\/demo\/?/, ''))); + } + + if (url.pathname === '/demo') { + return redirect(getDemoRedirectTarget(undefined)); + } + + const fileResponse = await staticFile( + url.pathname === '/' ? '/src/web/index.html' : url.pathname + ); + if (fileResponse) { + return fileResponse; + } + + return new Response(Bun.file(INDEX_FILE), { + headers: { 'content-type': 'text/html; charset=utf-8' }, + }); + }, +}); + +console.log(`Docs server running on http://localhost:${PORT}`); diff --git a/docs/src/web/components/AIGatewayDemo.tsx b/docs/src/web/components/AIGatewayDemo.tsx index f7915cd7f..e993a8a3e 100644 --- a/docs/src/web/components/AIGatewayDemo.tsx +++ b/docs/src/web/components/AIGatewayDemo.tsx @@ -32,7 +32,7 @@ function parseTokensHeader(header: string): number { // Note: Google/Gemini excluded due to streaming issues (see issue #248) const AVAILABLE_MODELS = [ { id: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', provider: 'OpenAI' }, - { id: 'gpt-5-mini', label: 'GPT-5 Mini', provider: 'OpenAI' }, + { id: 'gpt-5.4-nano', label: 'GPT-5.4 Nano', provider: 'OpenAI' }, { id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', provider: 'Anthropic' }, { id: 'claude-sonnet-4-5', label: 'Claude Sonnet', provider: 'Anthropic' }, { id: 'llama-3.3-70b-versatile', label: 'Llama 3.3 70B', provider: 'Groq' }, diff --git a/docs/src/web/components/SSEStreamDemo.tsx b/docs/src/web/components/SSEStreamDemo.tsx index 42eba9b5c..f4e97c3c9 100644 --- a/docs/src/web/components/SSEStreamDemo.tsx +++ b/docs/src/web/components/SSEStreamDemo.tsx @@ -23,7 +23,7 @@ interface StreamState { // Note: Google/Gemini excluded due to streaming issues (see issue #248) const MODELS = [ { value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', provider: 'OpenAI' }, - { value: 'gpt-5-mini', label: 'GPT-5 Mini', provider: 'OpenAI' }, + { value: 'gpt-5.4-nano', label: 'GPT-5.4 Nano', provider: 'OpenAI' }, { value: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', provider: 'Anthropic' }, { value: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5', provider: 'Anthropic' }, { value: 'llama-3.3-70b-versatile', label: 'Llama 3.3 70B', provider: 'Groq' }, diff --git a/docs/src/web/components/StreamingDemo.tsx b/docs/src/web/components/StreamingDemo.tsx index f5964acad..185c6f1d8 100644 --- a/docs/src/web/components/StreamingDemo.tsx +++ b/docs/src/web/components/StreamingDemo.tsx @@ -28,7 +28,7 @@ function estimateTokens(text: string): number { const MODELS = [ { value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', provider: 'OpenAI' }, - { value: 'gpt-5-mini', label: 'GPT-5 Mini', provider: 'OpenAI' }, + { value: 'gpt-5.4-nano', label: 'GPT-5.4 Nano', provider: 'OpenAI' }, { value: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', provider: 'Anthropic' }, { value: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5', provider: 'Anthropic' }, { value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite', provider: 'Google' }, diff --git a/docs/src/web/components/docs/copy-migration-prompt.tsx b/docs/src/web/components/docs/copy-migration-prompt.tsx index 5959ff6f5..bcc433766 100644 --- a/docs/src/web/components/docs/copy-migration-prompt.tsx +++ b/docs/src/web/components/docs/copy-migration-prompt.tsx @@ -68,7 +68,7 @@ No filesystem changes. No heuristics. Phase 1 — Comprehensive v0 Project Scan Recursively scan all files and folders that belong to the v0 project, including (but not limited to): src/agent*, src/agents* - src/api*, src/routes* + src/api*, src/frameworks* src/tools/ src/utils/, src/helpers/, src/lib/ Any other files contributing to runtime behavior or business logic diff --git a/docs/src/web/components/docs/nav-data.ts b/docs/src/web/components/docs/nav-data.ts index b20dc9467..ec5529c48 100644 --- a/docs/src/web/components/docs/nav-data.ts +++ b/docs/src/web/components/docs/nav-data.ts @@ -100,11 +100,6 @@ export const navData: NavSection[] = [ url: '/explorer/model-arena', description: 'Compare AI models using another AI as judge', }, - { - title: 'WebRTC', - url: '/explorer/webrtc', - description: 'Audio, video, and data channels directly between browsers', - }, { title: 'Queues', url: '/explorer/queue', @@ -127,178 +122,118 @@ export const navData: NavSection[] = [ url: '/get-started', items: [ { - title: 'What is Agentuity?', - url: '/get-started/what-is-agentuity', - description: 'The full-stack platform for building, deploying, and operating AI agents', + title: 'Quickstart', + url: '/get-started/quickstart', + description: 'Scaffold a Next.js starter, run it locally, and deploy it', }, { title: 'Installation', url: '/get-started/installation', - description: 'Set up your development environment', - }, - { - title: 'Quickstart', - url: '/get-started/quickstart', - description: 'Build your first agent in 5 minutes', + description: 'Install the Agentuity CLI, sign in, and create a framework starter', }, { title: 'Project Structure', url: '/get-started/project-structure', - description: 'Understand how Agentuity projects are organized', + description: 'See exactly what the create flow adds to a framework app', }, { title: 'App Configuration', url: '/get-started/app-configuration', - description: 'Configure your Agentuity project', - }, - ], - }, - { - title: 'Agents', - url: '/agents', - items: [ - { - title: 'When to Use', - url: '/agents/when-to-use', - description: 'When to create an agent vs handling requests directly in routes', - }, - { - title: 'Creating Agents', - url: '/agents/creating-agents', - description: 'Build agents with createAgent(), schemas, and handlers', - }, - { - title: 'Schema Libraries', - url: '/agents/schema-libraries', - description: 'Choose from built-in, Zod, Valibot, or ArkType for validation', + description: 'Configure scripts, agentuity.json, env vars, and service clients', }, { - title: 'AI Gateway', - url: '/agents/ai-gateway', - description: 'Automatic LLM routing with observability and cost tracking', - }, - { - title: 'AI SDK Integration', - url: '/agents/ai-sdk-integration', - description: 'Generate text, structured data, and streams with the Vercel AI SDK', - }, - { - title: 'Streaming Responses', - url: '/agents/streaming-responses', - description: 'Return real-time LLM output with streaming agents', - }, - { - title: 'State Management', - url: '/agents/state-management', - description: 'Request and thread state for stateful agents', - }, - { - title: 'Calling Other Agents', - url: '/agents/calling-other-agents', - description: 'Build multi-agent systems with type-safe agent-to-agent communication', - }, - { - title: 'Standalone Execution', - url: '/agents/standalone-execution', + title: 'What is Agentuity?', + url: '/get-started/what-is-agentuity', description: - 'Execute agents programmatically for cron jobs, bots, CLI tools, and background workers', - }, - { - title: 'Events & Lifecycle', - url: '/agents/events-lifecycle', - description: 'Lifecycle hooks for monitoring and extending agent behavior', + 'Build, deploy, and operate TypeScript framework apps with Agentuity services', }, ], }, { - title: 'Routes', - url: '/routes', + title: 'Frameworks', + url: '/frameworks', items: [ { - title: 'HTTP', - url: '/routes/http', - description: 'Define GET, POST, and other HTTP endpoints with Hono', + title: 'Next.js', + url: '/frameworks/nextjs', + description: + 'Connect Agentuity service clients, local development, and deploy packaging to an App Router project', }, { - title: 'Middleware', - url: '/routes/middleware', - description: 'Add authentication, validation, and request processing to your routes', + title: 'Nuxt', + url: '/frameworks/nuxt', + description: + 'Add Agentuity service clients, local development, and deployment metadata to a Nuxt app', }, { - title: 'Calling Agents', - url: '/routes/calling-agents', - description: 'Import and invoke agents from your routes', + title: 'React Router', + url: '/frameworks/react-router', + description: + 'Connect a React Router framework-mode app to Agentuity services and deploy it from the CLI', }, { - title: 'Cron', - url: '/routes/cron', - description: 'Run tasks on a schedule with the cron() middleware', + title: 'SvelteKit', + url: '/frameworks/sveltekit', + description: + 'Add Agentuity service clients, local development, and deploy validation to a SvelteKit app', }, { - title: 'WebSockets', - url: '/routes/websockets', - description: 'Real-time bidirectional communication with the websocket middleware', + title: 'Astro', + url: '/frameworks/astro', + description: + 'Add Agentuity service clients, local development, and deploy validation to an Astro app', }, { - title: 'SSE', - url: '/routes/sse', - description: 'Stream updates from server to client using SSE middleware', + title: 'Hono', + url: '/frameworks/hono', + description: 'Add Agentuity service clients and AI Gateway routing to a Hono app', }, { - title: 'Using WebRTC', - url: '/routes/webrtc', - description: 'Peer-to-peer audio, video, and data channels with the webrtc middleware', + title: 'Vite + React', + url: '/frameworks/vite-react', + description: + 'Build a Vite React SPA with a Bun server boundary for secrets and Agentuity service clients', }, { - title: 'Using Explicit Routing', - url: '/routes/explicit-routing', + title: 'TanStack Start', + url: '/frameworks/tanstack-start', description: - 'Pass your own Hono router to createApp() when you need custom mount paths or an exported router type', + 'Add Agentuity service clients to a TanStack Start app and validate the detected build path before deploying', }, ], }, { - title: 'Frontend', - url: '/frontend', + title: 'Build', + url: '/build', items: [ { - title: 'React Hooks', - url: '/frontend/react-hooks', - description: 'Provider, auth, analytics, and WebRTC hooks from @agentuity/react', - }, - { - title: 'RPC Client', - url: '/frontend/rpc-client', + title: 'Build Agents', + url: '/build/agents', description: - 'Type-safe API calls from any JavaScript environment using hc() from hono/client', - }, - { - title: 'Provider Setup', - url: '/frontend/provider-setup', - description: 'Legacy AgentuityProvider setup for @agentuity/react apps', + 'Structure model-backed workflows as plain server functions called from framework routes', }, { - title: 'Authentication', - url: '/frontend/authentication', - description: 'Add user authentication with Agentuity Auth', + title: 'Chat and Streaming', + url: '/build/chat-and-streaming', + description: + 'Stream model output from framework routes and persist chat history with KV storage', }, { - title: 'Deployment Scenarios', - url: '/frontend/deployment-scenarios', - description: - 'Deploy your frontend alongside agents or separately on Vercel, Netlify, etc.', + title: 'Tool Calling', + url: '/build/tool-calling', + description: 'Let models call bounded app functions from framework routes', }, { - title: 'Static Rendering', - url: '/frontend/static-rendering', + title: 'State and Memory', + url: '/build/state-and-memory', description: - 'Pre-render your frontend to static HTML for faster page loads and better SEO', + 'Store app state explicitly with KV, databases, cookies, and service clients', }, { - title: 'Advanced Hooks', - url: '/frontend/advanced-hooks', + title: 'Background Work', + url: '/build/background-work', description: - 'Advanced WebRTC callbacks plus low-level WebSocket and SSE client utilities', + 'Use queues, status records, and durable streams for work that should outlive a request', }, ], }, @@ -306,22 +241,6 @@ export const navData: NavSection[] = [ title: 'Services', url: '/services', items: [ - { - title: 'Database', - url: '/services/database', - items: [ - { - title: 'Postgres', - url: '/services/database/postgres', - description: 'Auto-reconnecting PostgreSQL client for serverless environments', - }, - { - title: 'Drizzle', - url: '/services/database/drizzle', - description: 'Type-safe database access with Drizzle ORM', - }, - ], - }, { title: 'Storage', url: '/services/storage', @@ -329,30 +248,43 @@ export const navData: NavSection[] = [ { title: 'Key-Value', url: '/services/storage/key-value', - description: - 'Fast key-based storage for caching, session data, and configuration', + description: 'Store small durable values by namespace and key with optional TTL', }, { title: 'Vector', url: '/services/storage/vector', - description: 'Semantic search and retrieval for knowledge bases and RAG systems', + description: 'Store documents and embeddings for semantic search and retrieval', }, { title: 'Object', url: '/services/storage/object', - description: "Durable file storage using Bun's native S3 APIs", + description: "Store files and binary data with Bun's native S3 APIs", }, { title: 'Durable Streams', url: '/services/storage/durable-streams', - description: - 'Streaming storage for large exports, audit logs, and real-time data', + description: 'Write generated output incrementally and keep it available by URL', }, { title: 'Custom', url: '/services/storage/custom', - description: - 'Local development storage and custom runtime storage implementations', + description: 'Swap storage backends behind small app-owned interfaces', + }, + ], + }, + { + title: 'Database', + url: '/services/database', + items: [ + { + title: 'Postgres', + url: '/services/database/postgres', + description: 'Use Bun-native tagged SQL with Agentuity-managed Postgres', + }, + { + title: 'Drizzle', + url: '/services/database/drizzle', + description: 'Type-safe database access with Drizzle ORM', }, ], }, @@ -369,10 +301,9 @@ export const navData: NavSection[] = [ 'Send and receive emails with managed addresses, destinations, and delivery tracking', }, { - title: 'Webhooks', - url: '/services/webhooks', - description: - 'Create webhook endpoints to receive HTTP callbacks with delivery tracking and retry', + title: 'Tasks', + url: '/services/tasks', + description: 'Track work items, issues, and agent activity with lifecycle management', }, { title: 'Schedules', @@ -380,22 +311,33 @@ export const navData: NavSection[] = [ description: 'Create platform-managed cron jobs with HTTP and sandbox destinations', }, { - title: 'Tasks', - url: '/services/tasks', + title: 'Webhooks', + url: '/services/webhooks', description: - 'Track work items, issues, and agent activity with built-in lifecycle management', + 'Create webhook endpoints to receive HTTP callbacks with delivery tracking and retry', }, { - title: 'Authentication', - url: '/services/authentication', - description: - 'Choose between Sign in with Agentuity and app-owned authentication for routes and apps', + title: 'Sandbox', + url: '/services/sandbox', + items: [ + { + title: 'SDK Usage', + url: '/services/sandbox/sdk-usage', + description: + 'Create, execute, inspect, and clean up sandboxes with SandboxClient', + }, + { + title: 'Snapshots', + url: '/services/sandbox/snapshots', + description: + 'Save sandbox filesystem states and reuse them as bases for new sandboxes', + }, + ], }, { - title: 'OIDC Provider', - url: '/services/oidc-provider', - description: - 'Add Agentuity account sign-in and scoped access to your app with OAuth 2.0 and OIDC', + title: 'AI Gateway', + url: '/services/ai-gateway', + description: 'Route supported LLM SDK calls through Agentuity during local development', }, { title: 'Coder', @@ -410,7 +352,7 @@ export const navData: NavSection[] = [ { title: 'Logging', url: '/services/observability/logging', - description: 'Collected logs for agents and routes', + description: 'Write structured logs from framework apps and Hono routes', }, { title: 'Tracing', @@ -421,7 +363,7 @@ export const navData: NavSection[] = [ { title: 'Sessions & Debugging', url: '/services/observability/sessions-debugging', - description: 'Debug agents using session IDs, CLI commands, and trace timelines', + description: 'Inspect session records, logs, and timelines', }, { title: 'Web Analytics', @@ -432,20 +374,45 @@ export const navData: NavSection[] = [ ], }, { - title: 'Sandbox', - url: '/services/sandbox', - items: [ - { - title: 'SDK Usage', - url: '/services/sandbox/sdk-usage', - description: 'Programmatic API for creating and managing sandboxes', - }, - { - title: 'Snapshots', - url: '/services/sandbox/snapshots', - description: 'Save and restore sandbox filesystem states for faster cold starts', - }, - ], + title: 'Authentication', + url: '/services/authentication', + description: + 'Choose between Agentuity OIDC and framework-owned authentication for v3 apps', + }, + { + title: 'OIDC Provider', + url: '/services/oidc-provider', + description: + 'Add Agentuity account sign-in and scoped access to your app with OAuth 2.0 and OIDC', + }, + ], + }, + { + title: 'Deploy & Operate', + url: '/deploy-operate', + items: [ + { + title: 'Local Development', + url: '/deploy-operate/local-development', + description: 'Run your framework dev script with Agentuity environment wiring.', + }, + { + title: 'Deploy Framework Apps', + url: '/deploy-operate/deploy-framework-apps', + description: + 'Register, configure, package, and ship a framework project with the Agentuity CLI.', + }, + { + title: 'Custom Domains', + url: '/deploy-operate/custom-domains', + description: + 'Attach your own domain to an Agentuity project, validate DNS, and let TLS provision automatically.', + }, + { + title: 'Environment Variables', + url: '/deploy-operate/environment-variables', + description: + 'Manage local .env files, cloud project variables, public values, and secrets.', }, ], }, @@ -456,17 +423,11 @@ export const navData: NavSection[] = [ { title: 'Tutorials', items: [ - { - title: 'Understanding Agents', - url: '/cookbook/tutorials/understanding-agents', - description: - 'Learn how AI agents use tools, run in loops with stopping conditions, and use LLMs to complete tasks autonomously', - }, { title: 'RAG Agent', url: '/cookbook/tutorials/rag-agent', description: - 'Create a retrieval-augmented generation agent with vector search and citations', + 'Index documents into vector storage, retrieve the closest matches, and answer with citations', }, ], }, @@ -521,66 +482,68 @@ export const navData: NavSection[] = [ ], }, { - title: 'Autonomous Research', - url: '/cookbook/patterns/autonomous-research', - description: - 'Build a recursive research loop using the Anthropic SDK with native tool calling', + title: 'Chat with History', + url: '/cookbook/patterns/chat-with-history', + description: 'Store chat history with key-value storage from a framework route', }, { - title: 'Background Tasks', - url: '/cookbook/patterns/background-tasks', - description: 'Use waitUntil to return quickly while background work continues', + title: 'Product Search', + url: '/cookbook/patterns/product-search', + description: + 'Semantic product search with metadata filters and an optional model recommendation', }, { - title: 'Chat with History', - url: '/cookbook/patterns/chat-with-history', + title: 'Webhook Handler', + url: '/cookbook/patterns/webhook-handler', description: - 'Build a chat agent that remembers previous messages using thread state', + 'Verify a signed external webhook in a framework route and hand the payload to a queue', }, { title: 'Cron with Storage', url: '/cookbook/patterns/cron-with-storage', - description: 'Cache scheduled task results in KV for later retrieval', + description: + 'Refresh data on a schedule, cache it in key-value storage, and serve fast reads from a normal route', }, { - title: 'Hono RPC + TanStack', - url: '/cookbook/patterns/hono-rpc-tanstack-query', + title: 'Background Tasks', + url: '/cookbook/patterns/background-tasks', description: - 'Get end-to-end type safety between your Agentuity API routes and React frontend using Hono RPC and TanStack Query', + 'Return a fast response while side effects continue in the background, plus when to upgrade to a durable queue', }, { - title: 'LLM as a Judge', - url: '/cookbook/patterns/llm-as-a-judge', + title: 'Autonomous Research', + url: '/cookbook/patterns/autonomous-research', description: - 'Use LLMs to evaluate and score agent outputs for quality, safety, and compliance', + 'Build a recursive research loop using the Anthropic SDK Messages API and native tool_use blocks', }, { - title: 'Server Utilities', - url: '/cookbook/patterns/server-utilities', + title: 'LLM as a Judge', + url: '/cookbook/patterns/llm-as-a-judge', description: - 'Use storage, queues, logging, and error handling utilities from external backends like Next.js or Express', + "Use a model to evaluate another model's output for quality, grounding, or comparison", }, { - title: 'Product Search', - url: '/cookbook/patterns/product-search', - description: 'Semantic product search with metadata filtering', + title: 'Web Exploration', + url: '/cookbook/patterns/web-exploration', + description: + 'Run a headless browser inside a sandbox so an agent can navigate, screenshot, and extract content under isolation', }, { - title: 'Tailwind Setup', - url: '/cookbook/patterns/tailwind-setup', - description: 'Add Tailwind CSS styling to your Agentuity frontend', + title: 'Hono RPC + TanStack', + url: '/cookbook/patterns/hono-rpc-tanstack-query', + description: + 'Share Hono route types with a React client and wrap calls in TanStack Query', }, { - title: 'Web Exploration', - url: '/cookbook/patterns/web-exploration', + title: 'Server Utilities', + url: '/cookbook/patterns/server-utilities', description: - 'Run a headless browser in a sandbox to let agents browse, screenshot, and extract web content', + 'Use storage, queues, logging, and error handling utilities from external backends like Next.js or Express', }, { - title: 'Webhook Handler', - url: '/cookbook/patterns/webhook-handler', - description: - 'Handle incoming webhooks with signature verification and background processing', + title: 'Tailwind Setup', + url: '/cookbook/patterns/tailwind-setup', + description: 'Add Tailwind CSS to the framework app you deploy with Agentuity', }, ], }, @@ -590,50 +553,24 @@ export const navData: NavSection[] = [ { title: 'Mastra', url: '/cookbook/integrations/mastra', - description: - 'Deploy Mastra agents on Agentuity with persistent state, observability, and the AI Gateway', - }, - { - title: 'LangChain', - url: '/cookbook/integrations/langchain', - description: - "Build LangChain agents with Agentuity's deployment runtime, persistent storage, and observability", + description: 'Run a Mastra Agent inside a framework route with key-value memory', }, { title: 'OpenAI Agents SDK', url: '/cookbook/integrations/openai-agents', - description: - "Run OpenAI Agents SDK tool calling, handoffs, and structured output on Agentuity's deployment runtime", + description: 'Run an OpenAI Agents SDK agent loop inside a framework route', }, { title: 'Claude Agent SDK', url: '/cookbook/integrations/claude-agent', description: - 'Build conversational code intelligence agents with Claude Agent SDK and Agentuity sandboxes', + 'Run multi-turn Claude Agent sessions from a framework route with key-value session storage', }, { - title: 'Chat SDK', - url: '/cookbook/integrations/chat-sdk', - description: - 'Build multi-platform chatbots for Slack and Discord with Chat SDK and Agentuity agents', - }, - { - title: 'Next.js', - url: '/cookbook/integrations/nextjs', - description: - 'Connect a Next.js frontend to an Agentuity backend using rewrites and direct router types', - }, - { - title: 'TanStack Start', - url: '/cookbook/integrations/tanstack-start', - description: - 'Connect a TanStack Start frontend to an Agentuity backend using a Vite proxy and direct router types', - }, - { - title: 'Turborepo', - url: '/cookbook/integrations/turborepo', + title: 'LangChain', + url: '/cookbook/integrations/langchain', description: - 'Add Agentuity as a workspace app, share schemas across packages, and import router types directly', + 'Run a LangChain ReAct agent inside a framework route with tools and middleware', }, ], }, @@ -642,18 +579,41 @@ export const navData: NavSection[] = [ { title: 'Community', url: '/community', - items: [ - { - title: 'Inbound Email Agent', - url: '/community/inbound-email-agent', - description: 'Create an AI email auto-responder with Agentuity + Inbound webhooks.', - }, - ], + items: [], }, { title: 'Reference', url: '/reference', items: [ + { + title: 'Standalone Packages', + url: '/reference/standalone-packages', + description: + 'Use Agentuity service clients from Node.js, Bun, and server framework code', + }, + { + title: 'SDK Reference', + url: '/reference/sdk-reference', + items: [ + { + title: 'Coder', + url: '/reference/sdk-reference/coder', + description: 'Manage AI coding sessions, workspaces, and skills with CoderClient', + }, + { + title: 'Communication', + url: '/reference/sdk-reference/communication', + description: + 'Call shared workflow functions from routes, workers, scripts, and other server code', + }, + { + title: 'Schema', + url: '/reference/sdk-reference/schema', + description: + 'Validate route inputs, shared function inputs, and structured outputs with StandardSchema support', + }, + ], + }, { title: 'API Reference', url: '/reference/api', @@ -790,7 +750,7 @@ export const navData: NavSection[] = [ title: 'Build Configuration', url: '/reference/cli/build-configuration', description: - 'Customize the build process with Vite plugins and build-time constants', + 'Build framework apps into Agentuity deployment bundles with launch metadata and static assets.', }, { title: 'Claude Code Plugin', @@ -817,20 +777,19 @@ export const navData: NavSection[] = [ { title: 'Deployment', url: '/reference/cli/deployment', - description: - 'Deploy your agents to Agentuity Cloud with automatic infrastructure provisioning.', + description: 'Deploy registered framework apps to Agentuity Cloud.', }, { title: 'Development', url: '/reference/cli/development', description: - 'Run the development server with hot reload, type checking, and public URL support.', + 'Run your framework development server with Agentuity environment wiring.', }, { title: 'Getting Started', url: '/reference/cli/getting-started', description: - 'Install the Agentuity CLI and authenticate to start building agents.', + 'Install the Agentuity CLI, sign in, create framework projects, and register existing apps.', }, { title: 'Git Integration', @@ -847,8 +806,7 @@ export const navData: NavSection[] = [ { title: 'Managing OAuth Apps', url: '/reference/cli/oauth', - description: - 'Create and manage OAuth/OIDC applications for third-party integrations from the CLI', + description: 'Create and manage OAuth/OIDC applications from the CLI', }, { title: 'OpenCode Plugin', @@ -875,102 +833,6 @@ export const navData: NavSection[] = [ }, ], }, - { - title: 'SDK Reference', - url: '/reference/sdk-reference', - items: [ - { - title: 'Agents', - url: '/reference/sdk-reference/agents', - description: - 'Define agents with createAgent(), configure schemas, and write handlers', - }, - { - title: 'Application Entry', - url: '/reference/sdk-reference/application-entry', - description: - 'Initialize your Agentuity app with createApp() and configure the runtime entry file', - }, - { - title: 'Coder', - url: '/reference/sdk-reference/coder', - description: 'Manage AI coding sessions, workspaces, and skills with CoderClient', - }, - { - title: 'Communication', - url: '/reference/sdk-reference/communication', - description: 'Call agents from routes or other agents with type-safe imports', - }, - { - title: 'Context API', - url: '/reference/sdk-reference/context-api', - description: 'Storage, logging, and services available via the ctx.* object', - }, - { - title: 'Email Service', - url: '/reference/sdk-reference/email-service', - description: 'Send emails and manage addresses with ctx.email', - }, - { - title: 'Events', - url: '/reference/sdk-reference/events', - description: 'Lifecycle hooks for monitoring agent, session, and thread events', - }, - { - title: 'Observability', - url: '/reference/sdk-reference/observability', - description: - 'Collected logs and OpenTelemetry tracing via ctx.logger and ctx.tracer', - }, - { - title: 'Queue Service', - url: '/reference/sdk-reference/queue-service', - description: 'Publish messages and manage queues with ctx.queue', - }, - { - title: 'Router', - url: '/reference/sdk-reference/router', - description: 'HTTP endpoints, middleware, WebSocket, SSE, and cron handlers', - }, - { - title: 'Runtime Utilities', - url: '/reference/sdk-reference/advanced', - description: - 'File imports, standalone execution, context detection, process lifecycle, and build metadata', - }, - { - title: 'Sandbox Service', - url: '/reference/sdk-reference/sandbox-service', - description: 'Run code in isolated containers with ctx.sandbox', - }, - { - title: 'Schedule Service', - url: '/reference/sdk-reference/schedule-service', - description: 'Create and manage cron-based scheduled jobs with ctx.schedule', - }, - { - title: 'Schema', - url: '/reference/sdk-reference/schema', - description: 'Type-safe runtime validation with StandardSchema support', - }, - { - title: 'Storage', - url: '/reference/sdk-reference/storage', - description: 'KV, Vector, Database, Object, and Stream storage reference', - }, - { - title: 'Task Service', - url: '/reference/sdk-reference/task-service', - description: 'Track work items with lifecycle management via ctx.task', - }, - ], - }, - { - title: 'Standalone Packages', - url: '/reference/standalone-packages', - description: - 'Use Agentuity services from any Node.js or Bun application without the full runtime', - }, { title: 'GitHub App', url: '/reference/github-app', @@ -982,11 +844,26 @@ export const navData: NavSection[] = [ url: '/reference/gravity-network', description: "The layered infrastructure powering Agentuity's services", }, + ], + }, + { + title: 'Migration', + url: '/migration', + items: [ { - title: 'Migration Guide', - url: '/reference/migration-guide', - description: - 'Migrate from v1 to v2 for explicit routing, Hono-native routers, and standard Vite config.', + title: 'From v2', + url: '/migration/from-v2', + description: 'Move a v2 runtime app toward the v3 framework-first app shape.', + }, + { + title: 'Runtime to Frameworks', + url: '/migration/runtime-to-frameworks', + description: 'Translate v2 runtime concepts into the v3 framework-first model.', + }, + { + title: 'Migration CLI', + url: '/migration/migrate-cli', + description: 'Run the Agentuity migration tool for v2 to v3 projects.', }, ], }, diff --git a/docs/src/web/content/AGENTS.md b/docs/src/web/content/AGENTS.md index 204b628b1..1f5ac5223 100644 --- a/docs/src/web/content/AGENTS.md +++ b/docs/src/web/content/AGENTS.md @@ -10,28 +10,31 @@ Writing conventions for Agentuity docs pages in this directory. 4. **Scannable**: Headings, callouts, inline comments that explain "why" not "what" 5. **Benefit-focused, not salesy**: Explain _why_ someone would use a feature without hollow adjectives 6. **Source-verified**: Read SDK source and AGENTS.md files before documenting APIs or CLI flags. +7. **Framework-native**: Check current upstream framework docs before documenting framework examples. Local Agentuity verification is required, but working code is not enough if the framework shape is not idiomatic. ## Exemplar Pages Before writing a new page, read these as reference implementations: -- **Feature doc**: `agents/creating-agents.mdx` -- context-then-code flow, callouts, progressive examples +- **Feature doc**: `build/agents.mdx` -- context-then-code flow, callouts, progressive examples - **Service doc**: `services/storage/key-value.mdx` -- comparison table, access patterns, comprehensive operations -- **Cookbook pattern**: `cookbook/patterns/chat-with-history.mdx` -- concise, code highlights, thread state +- **Cookbook pattern**: `cookbook/patterns/chat-with-history.mdx` -- concise, code-first, key-value history - **Getting started**: `get-started/quickstart.mdx` -- step-by-step, CardLinks, tips -- **Reference**: `agents/ai-gateway.mdx` -- provider tables, how-it-works flow -- **SDK Reference**: `reference/sdk-reference/storage.mdx` -- hybrid narrative + structured method docs +- **Reference**: `services/ai-gateway.mdx` -- provider tables, how-it-works flow +- **SDK Reference**: `reference/sdk-reference/coder.mdx` -- hybrid narrative + structured method docs + +Framework and Build pages still need the same depth as feature and service docs: when-to-use guidance, a complete example, validation steps, gotchas, and source links. Do not ship pages that are only setup commands plus a trivial route snippet. ## Page Types | Type | Structure | Example | | -------------------- | ------------------------------------------------ | -------------------------------- | | **Getting started** | Step-by-step, minimal options, one happy path | `get-started/quickstart.mdx` | -| **Feature doc** | Context, basic, advanced, best practices | `agents/creating-agents.mdx` | +| **Feature doc** | Context, basic, advanced, best practices | `build/agents.mdx` | | **Service doc** | When-to-use table, access patterns, operations | `services/storage/key-value.mdx` | | **Cookbook pattern** | Problem statement, complete solution, variations | `cookbook/patterns/*.mdx` | | **Reference** | Factual, tables, complete flag/option lists | `reference/cli/*.mdx` | -| **SDK Reference** | Narrative intro, then structured method docs | `reference/sdk-reference/storage.mdx` | +| **SDK Reference** | Narrative intro, then structured method docs | `reference/sdk-reference/coder.mdx` | ### SDK Reference Page Convention @@ -43,7 +46,7 @@ SDK Reference pages use a hybrid format: narrative intro followed by structured --- title: Descriptive Title short_title: Short Sidebar Label -description: One sentence about ctx.X usage +description: One sentence about the SDK surface --- Brief intro (1-2 sentences), standalone callout if applicable, cross-link to how-to page. @@ -65,13 +68,13 @@ One sentence describing what this method does. **Example:** \```typescript -const result = await ctx.service.methodName('value', 'key'); +const result = await client.methodName('value', 'key'); \``` ``` Each method gets: **parameters + return type + example**. Mark optional parameters explicitly. Use param tables (`| Param | Type | Required | Description |`) for methods with many parameters. -**Exemplars:** `reference/sdk-reference/storage.mdx`, `reference/sdk-reference/agents.mdx` +**Exemplars:** `reference/sdk-reference/coder.mdx`, `reference/sdk-reference/schema.mdx` ## Page Structure @@ -103,6 +106,21 @@ Brief context: what is this for, when do you use it? (1-2 sentences) The sidebar is auto-generated at build time by `scripts/generate-nav-data.ts`. Page ordering within each section is controlled by the `meta.json` file in the same directory. When adding a new page, add its slug to the `pages` array in the relevant `meta.json`. +## Generated Reference Pages + +REST API reference pages in `src/web/content/reference/api/*.mdx` are generated +from service metadata and Zod schemas. Do not hand-edit those MDX files as the +source of truth. + +To update REST API docs: + +1. Edit the owning schema, type, or `packages/core/src/services/*/api-reference.ts` file. +2. Run `bun run scripts/generate-api-reference.ts` from `docs/`. +3. Commit the generated `reference/api/*.mdx` output if it changes. + +Generated diffs are expected when the source metadata changes. If the generated +output does not match what you want, fix the source metadata and regenerate. + ## Adding a New Page Every new page requires **three things**: @@ -134,7 +152,26 @@ Agentuity supports raw provider SDKs and AI SDK providers. When writing docs: - Keep feature docs provider-agnostic where possible - Use current model names in code examples; verify they're up to date before publishing - When listing providers or models in tables, link to each provider's model page -- See [AI Gateway](/agents/ai-gateway) for the canonical list of supported providers +- See [AI Gateway](/services/ai-gateway) for the canonical list of supported providers + +## Service Client Guidance + +When writing service docs, verify the package that owns the feature being documented. +Do not use a neighboring helper package as proof that another service works. + +Relational database helpers belong in database-specific, migration, or reference pages. +They should not be the default state example for unrelated services, and they should not +stand in for testing storage, messaging, execution, observability, identity, or other +dedicated service clients. + +Examples: + +- for key-value docs, test `KeyValueClient` directly instead of proving state storage + with a SQL helper +- for queues, tasks, schedules, email, webhooks, sandboxes, and Coder, verify the + dedicated client or CLI surface that owns that behavior +- for relational data, use the database docs and frame the example as app-owned + Postgres or trusted database administration ## Code Examples @@ -145,11 +182,14 @@ Code blocks fall into two categories: General rules: -- Use `ctx.logger` in server/agent examples, not `console.log` +- Use `c.var.logger` in Hono route examples or `logger` from `@agentuity/telemetry` in standalone server examples, not `console.log` - Inline comments explain intent ("why"), not syntax ("what") - No `// @ts-ignore`, `// eslint-disable`, or other suppression comments - Error handling: include in substantial examples, optional in short ones - Strip boilerplate: show only the feature being demonstrated +- Use `// [!code highlight]` on the smallest set of lines that teach the point + the surrounding paragraph makes. Prefer 1-3 highlighted lines per block, and + avoid highlighting imports unless the import itself is the point. - Use a balance of raw SDK providers and AI SDK providers (`openai()`, `anthropic()`) in examples - Prefer `s` from `@agentuity/schema` for schemas. Other StandardSchema libraries (Zod, ArkType, Valibot) are equally valid and should appear across examples to show the SDK is schema-agnostic @@ -185,7 +225,7 @@ Available components in doc pages: ### Links and Callouts -- **Cross-links** include context: "See [Streaming Responses](/agents/streaming-responses) for chunked output patterns" not "See also: Streaming" +- **Cross-links** include context: "See [Chat and Streaming](/build/chat-and-streaming) for chunked output patterns" not "See also: Streaming" - **External links**: link on first mention. Don't re-link on the same page - **Canonical docs**: link to existing docs instead of re-explaining. One location is canonical, others link to it - **Callouts**: `info` for context and clarifications, `warning` for gotchas and required setup, `tip` for optimizations and advanced patterns @@ -219,7 +259,7 @@ Available components in doc pages: - First code block appears early - Standalone examples have imports and are runnable -- `ctx.logger` in agents, `c.var.logger` in routes (not `console.log`) +- `c.var.logger` in Hono route examples and `logger` from `@agentuity/telemetry` in standalone server examples, not `console.log` - No suppression comments (`@ts-ignore`, `eslint-disable`) - Optional parameters explicitly marked - Model names are current; provider tables link to model pages diff --git a/docs/src/web/content/agents/ai-gateway.mdx b/docs/src/web/content/agents/ai-gateway.mdx deleted file mode 100644 index 6aeeb6be7..000000000 --- a/docs/src/web/content/agents/ai-gateway.mdx +++ /dev/null @@ -1,168 +0,0 @@ ---- -title: Using the AI Gateway -short_title: AI Gateway -description: Automatic LLM routing with observability and cost tracking ---- - -Agentuity's AI Gateway routes LLM requests through managed infrastructure, giving you observability and cost tracking across all model providers. - -## How It Works - -When you make LLM requests from your agents, they're *automatically* routed through the AI Gateway: - -``` -Your Agent → AI Gateway → Provider API (OpenAI, Anthropic, etc.) -``` - -The AI Gateway provides: - -- **Consolidated billing** across all LLM providers -- **Automatic observability** with token tracking and latency metrics -- **Request logging** visible in the Agentuity console -- **No configuration required** when using your SDK key - -## Using the AI Gateway - -The AI Gateway works automatically, whether you use provider SDKs directly (Anthropic, OpenAI, Groq), the [Vercel AI SDK](https://ai-sdk.dev), or frameworks like Mastra and LangGraph. No configuration needed. - -### Provider SDKs - -Use provider SDKs directly and get AI Gateway routing automatically: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import Anthropic from '@anthropic-ai/sdk'; -import OpenAI from 'openai'; -import Groq from 'groq-sdk'; -import { s } from '@agentuity/schema'; - -// Direct SDK clients route through the AI Gateway -const anthropic = new Anthropic(); -const openai = new OpenAI(); -const groq = new Groq(); - -const agent = createAgent('AnthropicChat', { - schema: { - input: s.object({ prompt: s.string() }), - output: s.object({ response: s.string() }), - }, - handler: async (ctx, input) => { - const result = await anthropic.messages.create({ - model: 'claude-sonnet-4-6', - max_tokens: 1024, - messages: [{ role: 'user', content: input.prompt }], - }); - - const text = result.content[0]?.type === 'text' - ? result.content[0].text - : ''; - - return { response: text }; - }, -}); - -export default agent; -``` - -### AI SDK Providers - -The [Vercel AI SDK](https://ai-sdk.dev) providers also route through Agentuity's AI Gateway: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { generateText } from 'ai'; -import { openai } from '@ai-sdk/openai'; -import { s } from '@agentuity/schema'; - -const agent = createAgent('TextGenerator', { - schema: { - input: s.object({ prompt: s.string() }), - output: s.object({ response: s.string() }), - }, - handler: async (ctx, input) => { - const { text } = await generateText({ - model: openai('gpt-5.4-mini'), - prompt: input.prompt, - }); - - return { response: text }; - }, -}); - -export default agent; -``` - -### Provider Imports - -All supported providers route through the AI Gateway: - -```typescript -// Provider SDKs -import Anthropic from '@anthropic-ai/sdk'; -import OpenAI from 'openai'; -import Groq from 'groq-sdk'; - -// AI SDK providers -import { openai } from '@ai-sdk/openai'; -import { anthropic } from '@ai-sdk/anthropic'; -import { google } from '@ai-sdk/google'; -import { xai } from '@ai-sdk/xai'; -import { deepseek } from '@ai-sdk/deepseek'; -import { groq } from '@ai-sdk/groq'; -import { mistral } from '@ai-sdk/mistral'; -import { cohere } from '@ai-sdk/cohere'; -import { perplexity } from '@ai-sdk/perplexity'; -``` - -## Supported Providers - -| Provider | Example Models | -|----------|---------------| -| [OpenAI](https://developers.openai.com/api/docs/models) | `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano` | -| [Anthropic](https://platform.claude.com/docs/en/about-claude/models/overview) | `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-haiku-4-5` | -| [Google](https://ai.google.dev/gemini-api/docs/models) | `gemini-3.1-pro-preview`, `gemini-3-flash-preview`, `gemini-3.1-flash-lite-preview` | -| [xAI](https://docs.x.ai/docs/models/) | `grok-4.20-reasoning`, `grok-4-1-fast-reasoning`, `grok-code-fast-1` | -| [DeepSeek](https://api-docs.deepseek.com/api/list-models) | `deepseek-chat`, `deepseek-reasoner` | -| [Groq](https://console.groq.com/docs/models) | `llama-3.3-70b-versatile`, `openai/gpt-oss-120b`, `meta-llama/llama-4-scout-17b-16e-instruct` | -| [Mistral](https://docs.mistral.ai/getting-started/models) | `mistral-large-2512`, `devstral-2512`, `mistral-medium-2508` | -| [Cohere](https://docs.cohere.com/docs/models) | `command-a-03-2025`, `command-a-reasoning-08-2025` | -| [Perplexity](https://docs.perplexity.ai/docs/getting-started/models) | `sonar-pro`, `sonar` | - - -Provider catalogs and model IDs are updated often. Verify current availability in each provider's official docs. - - -## BYO API Keys - -Bypass the AI Gateway by setting your own API keys in `.env`: - -```dotenv title=".env" -OPENAI_API_KEY=sk-... -ANTHROPIC_API_KEY=sk-ant-... -GOOGLE_GENERATIVE_AI_API_KEY=... -XAI_API_KEY=... -DEEPSEEK_API_KEY=... -GROQ_API_KEY=... -MISTRAL_API_KEY=... -COHERE_API_KEY=... -PERPLEXITY_API_KEY=... -``` - -When these variables are set to your provider keys, requests go directly to the provider instead of through the AI Gateway. - -## AI Gateway vs BYO Keys - -| Aspect | AI Gateway | BYO API Keys | -|--------|------------|--------------| -| **Setup** | Just SDK key | Manage per-provider keys | -| **Cost tracking** | Automatic in console | Manual | -| **Observability** | Built-in token/latency metrics | Must configure separately | -| **Rate limits** | Shared pool | Your own limits | - -We recommend the AI Gateway for most projects. - -## Next Steps - -- [Using the AI SDK](/agents/ai-sdk-integration): Structured output, tool calling, and multi-turn conversations -- [Returning Streaming Responses](/agents/streaming-responses): Real-time chat UIs and progress indicators -- [Logging](/services/observability/logging): Debug requests and track LLM performance diff --git a/docs/src/web/content/agents/ai-sdk-integration.mdx b/docs/src/web/content/agents/ai-sdk-integration.mdx deleted file mode 100644 index ad810d47e..000000000 --- a/docs/src/web/content/agents/ai-sdk-integration.mdx +++ /dev/null @@ -1,257 +0,0 @@ ---- -title: Using the AI SDK -short_title: AI SDK Integration -description: Generate text, structured data, and streams with the Vercel AI SDK ---- - -The [Vercel AI SDK](https://ai-sdk.dev) provides a consistent API for LLM interactions with built-in streaming, structured output, and tool calling. - -Agentuity works with any approach. This page documents the AI SDK path specifically, and AI SDK is optional. You can also use provider SDKs directly (Anthropic, OpenAI), or frameworks like Mastra and LangGraph. See [Using the AI Gateway](/agents/ai-gateway) for examples with different libraries. - -## Installation - -Install the AI SDK and your preferred provider: - -```bash -bun add ai @ai-sdk/openai -``` - -Or with multiple providers: - -```bash -bun add ai @ai-sdk/openai @ai-sdk/anthropic @ai-sdk/google -``` - -## Generating Text - -Use `generateText` for simple completions: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { generateText } from 'ai'; -import { openai } from '@ai-sdk/openai'; -import { z } from 'zod'; - -const agent = createAgent('TextGenerator', { - schema: { - input: z.object({ prompt: z.string() }), - output: z.object({ response: z.string() }), - }, - handler: async (ctx, input) => { - const { text } = await generateText({ - model: openai('gpt-5.4-mini'), - prompt: input.prompt, - }); - - return { response: text }; - }, -}); - -export default agent; -``` - -### With System Prompt - -Add context with a system message: - -```typescript -// Inside your handler: -const { text } = await generateText({ - model: openai('gpt-5.4-mini'), - system: 'You are a concise technical assistant. Keep responses under 100 words.', - prompt: input.prompt, -}); -``` - -### With Message History - -Pass conversation history for multi-turn interactions: - -```typescript -// Inside your handler: -const { text } = await generateText({ - model: openai('gpt-5.4-mini'), - messages: [ - { role: 'system', content: 'You are a helpful assistant.' }, - { role: 'user', content: 'What is TypeScript?' }, - { role: 'assistant', content: 'TypeScript is a typed superset of JavaScript.' }, - { role: 'user', content: input.followUp }, - ], -}); -``` - -## Generating Structured Data - -Use `generateText` with `Output.object()` to get validated, typed responses: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { generateText, Output } from 'ai'; -import { openai } from '@ai-sdk/openai'; -import { z } from 'zod'; - -const SentimentSchema = z.object({ - sentiment: z.enum(['positive', 'negative', 'neutral']), - confidence: z.number().min(0).max(1), - keywords: z.array(z.string()), -}); - -const agent = createAgent('SentimentAnalyzer', { - schema: { - input: z.object({ text: z.string() }), - output: SentimentSchema, - }, - handler: async (ctx, input) => { - const { output } = await generateText({ - model: openai('gpt-5.4-mini'), - output: Output.object({ schema: SentimentSchema }), - prompt: `Analyze the sentiment of: "${input.text}"`, - }); - - // Schema match means no runtime type assertion needed - return output; - }, -}); - -export default agent; -``` - - -Define your schema once and reuse it for both `Output.object()` and your agent's output schema. This keeps types consistent throughout your codebase. - - -### Using `.describe()` for Field Hints - -Add `.describe()` to schema fields to guide the model on format and content: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { generateText, Output } from 'ai'; -import { openai } from '@ai-sdk/openai'; -import { z } from 'zod'; - -const EventSchema = z.object({ - title: z.string().describe('Event title, e.g. "Team standup"'), - date: z.string().describe('ISO 8601 date: YYYY-MM-DD'), - startTime: z.string().describe('24-hour format: HH:MM'), - duration: z.number().describe('Duration in minutes'), - attendees: z.array(z.string()).describe('List of attendee names'), -}); - -const agent = createAgent('EventExtractor', { - schema: { - input: z.object({ text: z.string() }), - output: EventSchema, - }, - handler: async (ctx, input) => { - const { output } = await generateText({ - model: openai('gpt-5.4-mini'), - output: Output.object({ schema: EventSchema }), - prompt: `Extract event details from: "${input.text}"`, - }); - - return output; - }, -}); - -export default agent; -``` - -The `.describe()` hints improve output consistency, especially for dates, times, and formatted strings. - -## Generating Streams - -Use `streamText` for real-time responses: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { streamText } from 'ai'; -import { openai } from '@ai-sdk/openai'; -import { z } from 'zod'; - -const agent = createAgent('StreamingChat', { - schema: { - input: z.object({ prompt: z.string() }), - stream: true, - }, - handler: async (ctx, input) => { - const { textStream } = streamText({ - model: openai('gpt-5.4-mini'), - prompt: input.prompt, - }); - - return textStream; - }, -}); - -export default agent; -``` - -For detailed streaming patterns, see [Streaming Responses](/agents/streaming-responses). - -## Switching Providers - -Change providers by swapping the import and model: - -```typescript -// OpenAI -import { openai } from '@ai-sdk/openai'; -const model = openai('gpt-5.4-mini'); - -// Anthropic -import { anthropic } from '@ai-sdk/anthropic'; -const model = anthropic('claude-sonnet-4-6'); - -// Google -import { google } from '@ai-sdk/google'; -const model = google('gemini-3-flash-preview'); - -// Groq (fast inference) -import { groq } from '@ai-sdk/groq'; -const model = groq('llama-3.3-70b-versatile'); -``` - -## Error Handling - -Wrap LLM calls in try-catch to handle errors gracefully: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { generateText } from 'ai'; -import { openai } from '@ai-sdk/openai'; -import { z } from 'zod'; - -const agent = createAgent('SafeGenerator', { - schema: { - input: z.object({ prompt: z.string() }), - output: z.object({ response: z.string() }), - }, - handler: async (ctx, input) => { - try { - const { text } = await generateText({ - model: openai('gpt-5.4-mini'), - prompt: input.prompt, - }); - - return { response: text }; - } catch (error) { - ctx.logger.error('LLM request failed', { error }); - return { response: 'I encountered an error processing your request.' }; - } - }, -}); - -export default agent; -``` - -## Best Practices - -- **Define output schemas** with [Zod](https://zod.dev), [Valibot](https://valibot.dev), or [ArkType](https://arktype.io) for type safety and validation -- **Add system prompts** to guide model behavior consistently -- **Handle errors gracefully** with fallback responses - -## Next Steps - -- [Using the AI Gateway](/agents/ai-gateway): Observability, cost tracking, and provider switching -- [Returning Streaming Responses](/agents/streaming-responses): Chat UIs and long-form content generation -- [LLM as a Judge](/cookbook/patterns/llm-as-a-judge): Quality checks and output validation diff --git a/docs/src/web/content/agents/calling-other-agents.mdx b/docs/src/web/content/agents/calling-other-agents.mdx deleted file mode 100644 index 0f94455dd..000000000 --- a/docs/src/web/content/agents/calling-other-agents.mdx +++ /dev/null @@ -1,694 +0,0 @@ ---- -title: Calling Other Agents -description: Build multi-agent systems with type-safe agent-to-agent communication ---- - -Break complex tasks into focused, reusable agents that communicate with type safety. Instead of building one large agent, create specialized agents that each handle a single responsibility. - -For method signatures and the agent registry API, see the [Communication Reference](/reference/sdk-reference/communication). - -## Basic Usage - -Import and call other agents directly: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; -import enrichmentAgent from '@agent/enrichment/agent'; - -const coordinator = createAgent('Coordinator', { - schema: { - input: s.object({ text: s.string() }), - output: s.object({ result: s.string() }), - }, - handler: async (ctx, input) => { - // Call another agent by importing it - const enriched = await enrichmentAgent.run({ - text: input.text, - }); - - return { result: enriched.enrichedText }; - }, -}); - -export default coordinator; -``` - -When both agents have schemas, TypeScript validates the input and infers the output type automatically. - - -Define schemas on all agents to enable full type inference. TypeScript will validate that inputs match expected types and provide autocomplete for outputs. - - -## Communication Patterns - -### Sequential Execution - -Process data through a series of agents where each step depends on the previous result: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; -import validatorAgent from '@agent/validator/agent'; -import enrichmentAgent from '@agent/enrichment/agent'; -import analysisAgent from '@agent/analysis/agent'; - -const pipeline = createAgent('Pipeline', { - schema: { - input: s.object({ rawData: s.string() }), - output: s.object({ - processed: s.object({ - summary: s.string(), - tags: s.array(s.string()), - }), - }), - }, - handler: async (ctx, input) => { - // Each step depends on the previous result - const validated = await validatorAgent.run({ - data: input.rawData, - }); - - const enriched = await enrichmentAgent.run({ - data: validated.cleanData, - }); - - const analyzed = await analysisAgent.run({ - data: enriched.enrichedData, - }); - - return { processed: analyzed }; - }, -}); - -export default pipeline; -``` - -Errors propagate automatically. If `validatorAgent` throws, subsequent agents never execute. - -### Parallel Execution - -Run multiple agents simultaneously when their operations are independent: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; -import webSearchAgent from '@agent/web-search/agent'; -import databaseAgent from '@agent/database/agent'; -import vectorSearchAgent from '@agent/vector-search/agent'; - -const searchAgent = createAgent('Search', { - schema: { - input: s.object({ query: s.string() }), - output: s.object({ - results: s.array( - s.object({ - title: s.string(), - url: s.optional(s.string()), - snippet: s.string(), - }) - ), - }), - }, - handler: async (ctx, input) => { - // Execute all searches in parallel - const [webResults, dbResults, vectorResults] = await Promise.all([ - webSearchAgent.run({ query: input.query }), - databaseAgent.run({ query: input.query }), - vectorSearchAgent.run({ query: input.query }), - ]); - - return { - results: [...webResults.items, ...dbResults.items, ...vectorResults.items], - }; - }, -}); - -export default searchAgent; -``` - -If each agent takes 1 second, parallel execution completes in 1 second instead of 3. - -### Background Execution - -Use `ctx.waitUntil()` for fire-and-forget operations that continue after returning a response: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; -import analyticsAgent from '@agent/analytics/agent'; - -const processor = createAgent('Processor', { - schema: { - input: s.object({ - data: s.record(s.string(), s.unknown()), - }), - output: s.object({ status: s.string(), id: s.string() }), - }, - handler: async (ctx, input) => { - const id = crypto.randomUUID(); - - // Start background processing - ctx.waitUntil(async () => { - await analyticsAgent.run({ - event: 'processed', - data: input.data, - }); - ctx.logger.info('Background processing completed', { id }); - }); - - // Return immediately - return { status: 'accepted', id }; - }, -}); - -export default processor; -``` - - -Use `ctx.waitUntil()` for analytics, logging, notifications, or any operation where the caller doesn't need the result. - - -### Conditional Routing - -Use an LLM to classify intent and route to the appropriate agent: - -```typescript -import supportAgent from '@agent/support/agent'; -import salesAgent from '@agent/sales/agent'; -import technicalAgent from '@agent/technical/agent'; - -handler: async (ctx, input) => { - // Classify with a fast model, using Groq (via AI Gateway) - const { object: intent } = await generateObject({ - model: groq('llama-3.3-70b-versatile'), - schema: z.object({ - agentType: z.enum(['support', 'sales', 'technical']), - }), - prompt: input.message, - }); - - // Route based on classification - switch (intent.agentType) { - case 'support': - return supportAgent.run(input); - case 'sales': - return salesAgent.run(input); - case 'technical': - return technicalAgent.run(input); - } -} -``` - -See [Full Example](#full-example) below for a complete implementation with error handling and logging. - -### Orchestrator Pattern - -An orchestrator is a coordinator agent that delegates work to specialized agents and combines their results. This pattern is useful for: - -- Multi-step content pipelines (generate → evaluate → refine) -- Parallel data gathering from multiple sources -- Workflows requiring different expertise (writer + reviewer + formatter) - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; -import writerAgent from '@agent/writer/agent'; -import evaluatorAgent from '@agent/evaluator/agent'; - -const orchestrator = createAgent('Orchestrator', { - schema: { - input: s.object({ topic: s.string() }), - output: s.object({ content: s.string(), score: s.number() }), - }, - handler: async (ctx, input) => { - // Step 1: Generate content - const draft = await writerAgent.run({ prompt: input.topic }); - - // Step 2: Evaluate quality - const evaluation = await evaluatorAgent.run({ - content: draft.text, - }); - - // Step 3: Return combined result - return { content: draft.text, score: evaluation.score }; - }, -}); - -export default orchestrator; -``` - - -The orchestrator pattern is common in AI workflows where you want to separate concerns (e.g., generation, evaluation, formatting) into focused agents. - - -## Public Agents - - -Agent imports only work within the same project. To call an agent in another project or organization, expose it through an HTTP route and call that route with `fetch()`. - - -Call the deployed route using standard HTTP: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; - -const ExternalResponse = s.object({ response: s.string() }); - -const agent = createAgent('External Caller', { - schema: { - input: s.object({ query: s.string() }), - output: s.object({ result: s.string() }), - }, - handler: async (ctx, input) => { - try { - const routeUrl = process.env.EXTERNAL_AGENT_ROUTE_URL; - if (!routeUrl) { - throw new Error('EXTERNAL_AGENT_ROUTE_URL is required'); - } - - const response = await fetch(routeUrl, { - method: 'POST', - body: JSON.stringify({ query: input.query }), - headers: { 'Content-Type': 'application/json' }, - }); - - if (!response.ok) { - throw new Error(`External agent route returned ${response.status}`); - } - - const data = ExternalResponse.parse(await response.json()); - return { result: data.response }; - } catch (error) { - ctx.logger.error('External agent call failed', { error }); - throw error; - } - }, -}); - -export default agent; -``` - -## Error Handling - -### Cascading Failures - -By default, errors propagate through the call chain: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; -import validatorAgent from '@agent/validator/agent'; -import processorAgent from '@agent/processor/agent'; - -const pipeline = createAgent('Pipeline', { - schema: { - input: s.object({ - documentId: s.string(), - content: s.string(), - }), - output: s.object({ - documentId: s.string(), - status: s.string(), - }), - }, - handler: async (ctx, input) => { - // If validatorAgent throws, execution stops here - const validated = await validatorAgent.run(input); - - // This never executes if validation fails - const processed = await processorAgent.run(validated); - - return processed; - }, -}); -``` - -This is the recommended pattern for critical operations where later steps cannot proceed without earlier results. - -### Graceful Degradation - -For optional operations, catch errors and continue: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; -import enrichmentAgent from '@agent/enrichment/agent'; -import processorAgent from '@agent/processor/agent'; - -const resilientProcessor = createAgent('Resilient Processor', { - schema: { - input: s.object({ - data: s.record(s.string(), s.unknown()), - }), - output: s.object({ - status: s.string(), - resultId: s.string(), - }), - }, - handler: async (ctx, input) => { - let enrichedData = input.data; - - // Try to enrich, but continue if it fails - try { - const enrichment = await enrichmentAgent.run({ - data: input.data, - }); - enrichedData = enrichment.data; - } catch (error) { - ctx.logger.warn('Enrichment failed, using original data', { - error: error instanceof Error ? error.message : String(error), - }); - } - - // Process with enriched data (or original if enrichment failed) - return await processorAgent.run({ data: enrichedData }); - }, -}); -``` - -### Retry Pattern - -Implement retry logic for unreliable operations: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; -import externalServiceAgent from '@agent/external-service/agent'; - -async function callWithRetry( - fn: () => Promise, - maxRetries: number = 3, - delayMs: number = 1000 -): Promise { - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - return await fn(); - } catch (error) { - if (attempt === maxRetries) throw error; - - // Exponential backoff - const delay = delayMs * Math.pow(2, attempt - 1); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - throw new Error('Retry failed'); -} - -const retryHandler = createAgent('Retry Handler', { - schema: { - input: s.object({ - endpoint: s.string(), - payload: s.record(s.string(), s.unknown()), - }), - output: s.object({ - status: s.string(), - requestId: s.string(), - }), - }, - handler: async (ctx, input) => { - const result = await callWithRetry(() => - externalServiceAgent.run(input) - ); - return result; - }, -}); -``` - -### Partial Failure Handling - -Handle mixed success/failure results with `Promise.allSettled()`: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; -import processingAgent from '@agent/processing/agent'; - -const batchProcessor = createAgent('Batch Processor', { - schema: { - input: s.object({ - items: s.array( - s.object({ - id: s.string(), - payload: s.record(s.string(), s.unknown()), - }) - ), - }), - output: s.object({ - successful: s.array( - s.object({ - itemId: s.string(), - status: s.string(), - }) - ), - failedCount: s.number(), - }), - }, - handler: async (ctx, input) => { - const results = await Promise.allSettled( - input.items.map((item) => processingAgent.run({ item })) - ); - - const successful = results - .filter((r) => r.status === 'fulfilled') - .map((r) => r.value); - - const failed = results - .filter((r) => r.status === 'rejected') - .map((r) => r.reason); - - if (failed.length > 0) { - ctx.logger.warn('Some operations failed', { failedCount: failed.length }); - } - - return { successful, failedCount: failed.length }; - }, -}); -``` - -## Best Practices - -### Keep Agents Focused - -Each agent should have a single, well-defined responsibility: - -```typescript -// Good: focused agents -const validatorAgent = createAgent('Validator', { /* validates data */ }); -const enrichmentAgent = createAgent('Enrichment', { /* enriches data */ }); -const analysisAgent = createAgent('Analysis', { /* analyzes data */ }); - -// Bad: monolithic agent -const megaAgent = createAgent('MegaAgent', { - handler: async (ctx) => { - // Validates, enriches, analyzes all in one place - }, -}); -``` - -Focused agents are easier to test, reuse, and maintain. - -### Use Schemas for Type Safety - -Define schemas on all agents for type-safe communication. See [Creating Agents](/agents/creating-agents) to learn more about using schemas. - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { z } from 'zod'; - -// Source agent with output schema -const source = createAgent('Source', { - schema: { - input: z.object({ id: z.string() }), - output: z.object({ - data: z.string(), - metadata: z.object({ timestamp: z.string() }), - }), - }, - handler: async (_ctx, input) => { - return { - data: `result:${input.id}`, - metadata: { timestamp: new Date().toISOString() }, - }; - }, -}); - -// Consumer agent - TypeScript validates the connection -const consumer = createAgent('Consumer', { - schema: { - input: z.object({ id: z.string() }), - output: z.object({ processed: z.string() }), - }, - handler: async (_ctx, input) => { - const result = await source.run({ id: input.id }); - // TypeScript knows result.data and result.metadata.timestamp exist - return { processed: result.data }; - }, -}); -``` - -### Share Context - -Agent calls share the same session context: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; -import processingAgent from '@agent/processing/agent'; - -const coordinator = createAgent('Coordinator', { - schema: { - input: s.object({ - userId: s.string(), - data: s.record(s.string(), s.unknown()), - }), - output: s.object({ - status: s.string(), - resultId: s.string(), - }), - }, - handler: async (ctx, input) => { - // Store data in thread state (async) - await ctx.thread.state.set('userId', input.userId); - - // Called agents can access the same thread state - const result = await processingAgent.run(input); - - // All agents share sessionId - ctx.logger.info('Processing complete', { sessionId: ctx.sessionId }); - - return result; - }, -}); -``` - -Use this for tracking context, sharing auth data, and maintaining conversation state. - -## Full Example - -A customer support router that combines multiple patterns: conditional routing, graceful degradation, and background analytics. - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { generateObject } from 'ai'; -import { groq } from '@ai-sdk/groq'; -import { z } from 'zod'; -import supportAgent from '@agent/support/agent'; -import salesAgent from '@agent/sales/agent'; -import billingAgent from '@agent/billing/agent'; -import generalAgent from '@agent/general/agent'; -import analyticsAgent from '@agent/analytics/agent'; - -const IntentSchema = z.object({ - agentType: z.enum(['support', 'sales', 'billing', 'general']), - confidence: z.number().min(0).max(1), - reasoning: z.string(), -}); - -const router = createAgent('Customer Router', { - schema: { - input: z.object({ message: z.string() }), - output: z.object({ - response: z.string(), - handledBy: z.string(), - }), - }, - handler: async (ctx, input) => { - let intent: z.infer; - let handledBy = 'general'; - - // Classify intent with graceful degradation - try { - const result = await generateObject({ - model: groq('llama-3.3-70b-versatile'), - schema: IntentSchema, - system: 'Classify the customer message by intent.', - prompt: input.message, - temperature: 0, - }); - intent = result.object; - - ctx.logger.info('Intent classified', { - type: intent.agentType, - confidence: intent.confidence, - }); - } catch (error) { - // Fallback to general agent if classification fails - ctx.logger.warn('Classification failed, using fallback', { - error: error instanceof Error ? error.message : String(error), - }); - intent = { agentType: 'general', confidence: 0, reasoning: 'fallback' }; - } - - // Route to specialist agent - let response: string; - try { - switch (intent.agentType) { - case 'support': - const supportResult = await supportAgent.run({ - message: input.message, - context: intent.reasoning, - }); - response = supportResult.response; - handledBy = 'support'; - break; - - case 'sales': - const salesResult = await salesAgent.run({ - message: input.message, - context: intent.reasoning, - }); - response = salesResult.response; - handledBy = 'sales'; - break; - - case 'billing': - const billingResult = await billingAgent.run({ - message: input.message, - context: intent.reasoning, - }); - response = billingResult.response; - handledBy = 'billing'; - break; - - default: - const generalResult = await generalAgent.run({ - message: input.message, - }); - response = generalResult.response; - handledBy = 'general'; - } - } catch (error) { - ctx.logger.error('Specialist agent failed', { error, intent }); - response = 'I apologize, but I encountered an issue. Please try again.'; - handledBy = 'error'; - } - - // Log analytics in background (doesn't block response) - ctx.waitUntil(async () => { - await analyticsAgent.run({ - event: 'customer_interaction', - intent: intent.agentType, - confidence: intent.confidence, - handledBy, - sessionId: ctx.sessionId, - }); - }); - - return { response, handledBy }; - }, -}); - -export default router; -``` - -This example combines several patterns: -- Use an LLM to classify intent and route to specialist agents -- Handle failures gracefully: fallback to general agent if classification fails, friendly error message if specialists fail -- Log analytics in the background with `waitUntil()` so the response isn't delayed - -## Next Steps - -- [State Management](/agents/state-management): Share data across agent calls with thread and session state -- [LLM as a Judge](/cookbook/patterns/llm-as-a-judge): Add quality checks to your agent workflows diff --git a/docs/src/web/content/agents/creating-agents.mdx b/docs/src/web/content/agents/creating-agents.mdx deleted file mode 100644 index 6a7b6f0da..000000000 --- a/docs/src/web/content/agents/creating-agents.mdx +++ /dev/null @@ -1,361 +0,0 @@ ---- -title: Creating Agents -description: Build agents with createAgent(), schemas, and handlers ---- - -Each agent encapsulates a handler function, input/output validation, and metadata in a single unit that can be invoked from routes, other agents, or scheduled tasks. - -## Basic Agent - -Create an agent with `createAgent()`, providing a name and handler function: - -```typescript -import { createAgent } from '@agentuity/runtime'; - -const agent = createAgent('Greeter', { - handler: async (ctx) => { - ctx.logger.info('Processing request'); - return { message: 'Hello from agent!' }; - }, -}); - -export default agent; -``` - -For the complete `createAgent()` signature and all configuration options, see the [Agents Reference](/reference/sdk-reference/agents). - -The handler always receives `ctx`, the agent context with logging, storage, and state management. When you define `schema.input`, the handler receives validated `input` as its second parameter. - - -In agents, access services directly on `ctx`: `ctx.logger`, `ctx.kv`, `ctx.thread`, etc. - -In routes, use [Hono's context](https://hono.dev/docs/api/context): `c.var.logger` or `c.get('logger')`, `c.var.kv`, `c.var.thread`, etc. - - -## Adding LLM Capabilities - -Most agents use an LLM for inference. Here's an agent using the [AI SDK](https://ai-sdk.dev): - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { generateText } from 'ai'; -import { openai } from '@ai-sdk/openai'; -import { s } from '@agentuity/schema'; - -const agent = createAgent('Assistant', { - schema: { - input: s.object({ prompt: s.string() }), - output: s.object({ response: s.string() }), - }, - handler: async (ctx, { prompt }) => { - const { text } = await generateText({ - model: openai('gpt-5-mini'), - prompt, - }); - - return { response: text }; - }, -}); - -export default agent; -``` - -You can also use provider SDKs directly: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; -import OpenAI from 'openai'; - -const client = new OpenAI(); - -const agent = createAgent('Assistant', { - description: 'An agent using OpenAI SDK directly', - schema: { - input: s.object({ prompt: s.string() }), - output: s.string(), - }, - handler: async (ctx, { prompt }) => { - const completion = await client.chat.completions.create({ - model: 'gpt-5-mini', - messages: [{ role: 'user', content: prompt }], - }); - - return completion.choices[0]?.message?.content ?? ''; - }, -}); - -export default agent; -``` - -Or use Groq for fast inference with open-source models: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; -import Groq from 'groq-sdk'; - -const client = new Groq(); - -const agent = createAgent('Assistant', { - description: 'An agent using Groq SDK with open-source models', - schema: { - input: s.object({ prompt: s.string() }), - output: s.string(), - }, - handler: async (ctx, { prompt }) => { - const completion = await client.chat.completions.create({ - model: 'llama-3.3-70b-versatile', - messages: [{ role: 'user', content: prompt }], - }); - - return completion.choices[0]?.message?.content ?? ''; - }, -}); - -export default agent; -``` - -For streaming, structured output, and more patterns, see [Using the AI SDK](/agents/ai-sdk-integration). - -## Adding Schema Validation - -Define input and output schemas for type safety and runtime validation. Agentuity includes a lightweight built-in schema library: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; - -const agent = createAgent('Contact Form', { - schema: { - input: s.object({ - email: s.string(), - message: s.string(), - }), - output: s.object({ - success: s.boolean(), - id: s.string(), - }), - }, - handler: async (ctx, input) => { - // input is typed as { email: string, message: string } - ctx.logger.info('Received message', { from: input.email }); - - return { - success: true, - id: crypto.randomUUID(), - }; - }, -}); - -export default agent; -``` - -You can also use [Zod](https://zod.dev) for more advanced validation: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { z } from 'zod'; - -const agent = createAgent('Contact Form', { - schema: { - input: z.object({ - email: z.string().email(), - message: z.string().min(1), - }), - output: z.object({ - success: z.boolean(), - id: z.string(), - }), - }, - handler: async (ctx, input) => { - ctx.logger.info('Received message', { from: input.email }); - - return { - success: true, - id: crypto.randomUUID(), - }; - }, -}); - -export default agent; -``` - -**Validation behavior:** -- Input is validated before the handler runs -- Output is validated before returning to the caller -- Invalid data throws an error with details about what failed - - -- **`@agentuity/schema`** — Lightweight, built-in, zero dependencies -- **[Zod](https://zod.dev)** — Popular, feature-rich, great ecosystem -- **[Valibot](https://valibot.dev)** — Tiny bundle size, tree-shakeable -- **[ArkType](https://arktype.io)** — TypeScript-native syntax - -All implement [StandardSchema](https://github.com/standard-schema/standard-schema). See [Schema Libraries](/agents/schema-libraries) for detailed examples. - - -### Type Inference - -TypeScript automatically infers types from your schemas. Don't add explicit type annotations to handler parameters: - -```typescript -// Good: types inferred from schema -handler: async (ctx, input) => { ... } - -// Bad: explicit types can cause issues -handler: async (ctx: AgentContext, input: MyInput) => { ... } -``` - -```typescript -const agent = createAgent('Search', { - schema: { - input: z.object({ - query: z.string(), - filters: z.object({ - category: z.enum(['tech', 'business', 'sports']), - limit: z.number().default(10), - }), - }), - output: z.object({ - results: z.array(z.string()), - total: z.number(), - }), - }, - handler: async (ctx, input) => { - // Full autocomplete for input.query and input.filters.category - const category = input.filters.category; // type: 'tech' | 'business' | 'sports' - - return { - results: ['result1', 'result2'], - total: 2, - }; - }, -}); -``` - -### Common Zod Patterns - -```typescript -z.object({ - // Strings - name: z.string().min(1).max(100), - email: z.string().email(), - url: z.string().url().optional(), - - // Numbers - age: z.number().min(0).max(120), - score: z.number().min(0).max(1), - - // Enums and literals - status: z.enum(['active', 'pending', 'complete']), - type: z.literal('user'), - - // Arrays and nested objects - tags: z.array(z.string()), - metadata: z.object({ - createdAt: z.date(), - version: z.number(), - }).optional(), - - // Defaults - limit: z.number().default(10), -}) -``` - -### Schema Descriptions for AI - -When using `generateObject()` from the AI SDK, add `.describe()` to help the LLM understand each field: - -```typescript -z.object({ - title: z.string().describe('Event title, concise, without names'), - startTime: z.string().describe('Start time in HH:MM format (e.g., 14:00)'), - priority: z.enum(['low', 'medium', 'high']).describe('Urgency level'), -}) -``` - -Call `.describe()` at the end of the chain: schema methods like `.min()` return new instances that don't inherit metadata. - -## Handler Context - -The handler context (`ctx`) provides access to Agentuity services: - -```typescript -handler: async (ctx, input) => { - // Logging (Remember: always use ctx.logger, not console.log) - ctx.logger.info('Processing', { data: input }); - ctx.logger.error('Something failed', { error }); - - // Identifiers - ctx.sessionId; // Unique per request (sess_...) - ctx.thread.id; // Conversation context (thrd_...) - ctx.current.name; // This agent's name - ctx.current.agentId; // Stable ID for namespacing state keys - - // State management - ctx.state.set('key', value); // Request-scoped (sync, cleared after response) - await ctx.thread.state.set('key', value); // Thread-scoped (async, up to 1 hour) - ctx.session.state.set('key', value); // Session-scoped - - // Storage - await ctx.kv.set('bucket', 'key', data); - await ctx.vector.search('namespace', { query: 'text' }); - - // Background tasks - ctx.waitUntil(async () => { - await ctx.kv.set('analytics', 'event', { timestamp: Date.now() }); - }); - - return { result }; -} -``` - -For detailed state management patterns, see [Managing State](/agents/state-management). - -## Agent Name and Description - -Every agent requires a name (first argument) and can include an optional description: - -```typescript -const agent = createAgent('Support Ticket Analyzer', { - description: 'Analyzes support tickets and extracts key information', - schema: { ... }, - handler: async (ctx, input) => { ... }, -}); -``` - -The name shows up in logs and the Agentuity console. Keep it short and specific so the agent is easy to spot when you have several running at once. - -## Best Practices - -- **Single responsibility**: Each agent should have one clear purpose -- **Always define schemas**: Schemas provide type safety and serve as documentation -- **Handle errors gracefully**: Wrap external calls in try-catch blocks -- **Keep handlers focused**: Move complex logic to helper functions - -```typescript -import processor from '@agent/processor/agent'; - -// Good: Clear, focused handler -handler: async (ctx, input) => { - try { - const enriched = await enrichData(input.data); - const result = await processor.run(enriched); - return { success: true, result }; - } catch (error) { - ctx.logger.error('Processing failed', { error }); - return { success: false, error: 'Processing failed' }; - } -} -``` - -## Next Steps - - -The [OpenCode plugin](/reference/cli/opencode-plugin) provides AI-assisted development for full-stack Agentuity projects, including agents, routes, frontend, and deployment. - - -- [Using the AI SDK](/agents/ai-sdk-integration): Add LLM capabilities with generateText and streamText -- [Managing State](/agents/state-management): Persist data across requests with thread and session state -- [Calling Other Agents](/agents/calling-other-agents): Build multi-agent workflows diff --git a/docs/src/web/content/agents/events-lifecycle.mdx b/docs/src/web/content/agents/events-lifecycle.mdx deleted file mode 100644 index cd63ce980..000000000 --- a/docs/src/web/content/agents/events-lifecycle.mdx +++ /dev/null @@ -1,226 +0,0 @@ ---- -title: Events & Lifecycle -description: Lifecycle hooks for monitoring and extending agent behavior ---- - -Events give you lifecycle hooks for observability and lightweight coordination. Use them for logging, metrics, analytics, and cleanup that should stay close to the runtime. - -## Agent Events - -Track individual agent execution with `started`, `completed`, and `errored` events: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; - -const agent = createAgent('TaskProcessor', { - schema: { - input: s.object({ task: s.string() }), - output: s.object({ result: s.string() }), - }, - handler: async (ctx, input) => { - ctx.logger.info('Processing task', { task: input.task }); - return { result: `Completed: ${input.task}` }; - }, -}); - -// Track execution timing -agent.addEventListener('started', (event, agent, ctx) => { - ctx.state.set('startTime', Date.now()); - ctx.logger.info('Agent started', { agent: agent.metadata.name }); -}); - -agent.addEventListener('completed', (event, agent, ctx) => { - const startTime = ctx.state.get('startTime'); - const duration = typeof startTime === 'number' ? Date.now() - startTime : 0; - - ctx.logger.info('Agent completed', { - agent: agent.metadata.name, - durationMs: duration, - }); - - // Warn on slow executions - if (duration > 1000) { - ctx.logger.warn('Slow execution detected', { duration, threshold: 1000 }); - } -}); - -agent.addEventListener('errored', (event, agent, ctx, error) => { - const startTime = ctx.state.get('startTime'); - const duration = typeof startTime === 'number' ? Date.now() - startTime : 0; - - ctx.logger.error('Agent failed', { - agent: agent.metadata.name, - error: error.message, - durationMs: duration, - }); -}); - -export default agent; -``` - -Agent listeners receive the event name, agent instance, context, and for `errored`, the thrown error. - -## App-Level Events - -Monitor all agents, sessions, and threads globally with the top-level `addEventListener()` function. Register these listeners at module scope during startup. - -```typescript -import { addEventListener } from '@agentuity/runtime'; - -// Track all agent executions -addEventListener('agent.started', (event, agent, ctx) => { - ctx.logger.info('Agent execution started', { - agent: agent.metadata.name, - sessionId: ctx.sessionId, - }); -}); - -addEventListener('agent.completed', (event, agent, ctx) => { - ctx.logger.info('Agent execution completed', { - agent: agent.metadata.name, - sessionId: ctx.sessionId, - }); -}); - -addEventListener('agent.errored', (event, agent, ctx, error) => { - ctx.logger.error('Agent execution failed', { - agent: agent.metadata.name, - error: error.message, - sessionId: ctx.sessionId, - }); -}); -``` - -Agent events receive `(eventName, agent, ctx)` or `(eventName, agent, ctx, error)`. Session and thread events receive `(eventName, session)` or `(eventName, thread)`. - -### Available App Events - -| Event | Description | -|-------|-------------| -| `agent.started` | Any agent starts execution | -| `agent.completed` | Any agent completes successfully | -| `agent.errored` | Any agent throws an error | -| `session.started` | New session begins | -| `session.completed` | Session ends | -| `thread.created` | New thread created | -| `thread.destroyed` | Thread is explicitly destroyed or removed by the active provider | - -## App Startup and Shutdown - -For shared resources, prefer module initialization plus `registerShutdownHook()`: - -```typescript -import { createApp, registerShutdownHook } from '@agentuity/runtime'; -import api from './src/api/index'; -import agents from './src/agent'; -import { db } from './db'; - -registerShutdownHook(async () => { - await db.close(); -}); - -export default await createApp({ - router: { path: '/api', router: api }, - agents, -}); -``` - - -Agent-level `setup` and `shutdown` are still useful for agent-local config exposed through `ctx.config`. For shared app-wide dependencies, the current stable path is module-scoped initialization plus `registerShutdownHook()`, not new examples built around `ctx.app`. - - -## Agent Lifecycle Hooks - -Individual agents can also define `setup` and `shutdown` functions for agent-specific initialization: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; - -const fetchValue = async (key: string): Promise => { - return `value:${key}`; -}; - -const agent = createAgent('CachedLookup', { - schema: { - input: s.object({ key: s.string() }), - output: s.object({ value: s.string() }), - }, - // Called once when the app starts, return value becomes ctx.config - setup: async () => { - const cache = new Map(); - return { cache }; - }, - // Called when the app shuts down - shutdown: async (_app, config) => { - config.cache.clear(); - }, - handler: async (ctx, input) => { - // ctx.config is typed from setup's return value - const cached = ctx.config.cache.get(input.key); - if (cached) { - return { value: cached }; - } - - // Fetch and cache - const value = await fetchValue(input.key); - ctx.config.cache.set(input.key, value); - return { value }; - }, -}); - -export default agent; -``` - -`setup` returns agent-specific config, exposed via `ctx.config`. Use it for agent-local caches, clients, or precomputed data that should stay scoped to that agent. - - -- **Module setup**: Shared resources like database pools, Redis clients, or service singletons -- **Agent setup**: Agent-local caches, preloaded models, or isolated clients exposed through `ctx.config` - - -## Shared State - -Event handlers on the same request can share data through `ctx.state`: - -```typescript -agent.addEventListener('started', (event, agent, ctx) => { - ctx.state.set('startTime', Date.now()); - ctx.state.set('metadata', { userId: '123', source: 'api' }); -}); - -agent.addEventListener('completed', (event, agent, ctx) => { - const startTime = ctx.state.get('startTime') as number; - const metadata = ctx.state.get('metadata') as Record; - - ctx.logger.info('Execution complete', { - duration: Date.now() - startTime, - ...metadata, - }); -}); -``` - - -Use `ctx.waitUntil()` in event handlers for non-blocking operations like sending metrics to external services: - -```typescript -agent.addEventListener('completed', (event, agent, ctx) => { - ctx.waitUntil(async () => { - await sendMetricsToExternalService({ agent: agent.metadata.name }); - }); -}); -``` - - -## Events and Output Review - -Use events for observability, logging, and side effects that belong close to execution. When you need a separate quality check after a response is complete, use a pattern like [LLM as a Judge](/cookbook/patterns/llm-as-a-judge). - -## Next Steps - -- [LLM as a Judge](/cookbook/patterns/llm-as-a-judge): Add post-response quality checks -- [State Management](/agents/state-management): Thread and session state patterns -- [Calling Other Agents](/agents/calling-other-agents): Multi-agent coordination -- [Logging](/services/observability/logging): Structured logging for debugging events -- [Tracing](/services/observability/tracing): Track timing across event handlers diff --git a/docs/src/web/content/agents/index.mdx b/docs/src/web/content/agents/index.mdx deleted file mode 100644 index e0f021640..000000000 --- a/docs/src/web/content/agents/index.mdx +++ /dev/null @@ -1,72 +0,0 @@ ---- -title: Agents -description: Build and configure AI agents ---- - -import { Bot, Database, Sparkles, Cpu, Radio, Archive, Phone, Play, Calendar, GitCompare } from 'lucide-react'; - -Agents are the core building blocks of Agentuity. Each agent handles a specific task with schema validation, AI integration, and lifecycle hooks. - - - } - /> - } - /> - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - diff --git a/docs/src/web/content/agents/meta.json b/docs/src/web/content/agents/meta.json deleted file mode 100644 index c4cd89ec0..000000000 --- a/docs/src/web/content/agents/meta.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "title": "Agents", - "pages": [ - "when-to-use", - "creating-agents", - "schema-libraries", - "ai-gateway", - "ai-sdk-integration", - "streaming-responses", - "state-management", - "calling-other-agents", - "standalone-execution", - "events-lifecycle" - ] -} diff --git a/docs/src/web/content/agents/schema-libraries.mdx b/docs/src/web/content/agents/schema-libraries.mdx deleted file mode 100644 index e2f89f083..000000000 --- a/docs/src/web/content/agents/schema-libraries.mdx +++ /dev/null @@ -1,325 +0,0 @@ ---- -title: Schema Libraries -description: Choose from built-in, Zod, Valibot, or ArkType for validation ---- - -Agentuity uses the [StandardSchema](https://github.com/standard-schema/standard-schema) interface for validation, which means you can use any compatible library. - -## Choosing a Library - -| Library | Syntax | Best For | -|---------|--------|----------| -| `@agentuity/schema` | `s.object({ name: s.string() })` | Lightweight validation without extra dependencies | -| [Zod](https://zod.dev) | `z.object({ name: z.string() })` | AI SDK integration (`.describe()` support) | -| [Valibot](https://valibot.dev) | `v.object({ name: v.string() })` | Smallest bundle size | -| [ArkType](https://arktype.io) | `type({ name: 'string' })` | TypeScript-native syntax, fast runtime | - -**Recommendation:** Use `@agentuity/schema` when you want a lightweight schema library for straightforward validation. It is not trying to replace Zod. Reach for Zod, Valibot, or ArkType when you need their broader validation, transformation, metadata, or ecosystem features. For example, Zod is useful with AI SDK structured output because `.describe()` adds field hints for the model. - - -For the complete `@agentuity/schema` API including coercion helpers and JSON Schema conversion, see [Schema Validation](/reference/sdk-reference/schema). - - -## Using @agentuity/schema - -The built-in schema library is a lightweight option for straightforward validation. - -### Basic Schema - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; - -const agent = createAgent('UserProcessor', { - schema: { - input: s.object({ - email: s.string(), - age: s.number(), - role: s.string(), - }), - output: s.object({ - success: s.boolean(), - userId: s.string(), - }), - }, - handler: async (ctx, input) => { - ctx.logger.info('Processing user', { email: input.email }); - return { - success: true, - userId: crypto.randomUUID(), - }; - }, -}); - -export default agent; -``` - -### Common Patterns - -```typescript -import { s } from '@agentuity/schema'; - -// Primitives -s.string() // Any string -s.number() // Any number -s.boolean() // Boolean -s.null() // Null value -s.undefined() // Undefined value -s.any() // Any value (no validation) -s.unknown() // Unknown value (safer any) - -// Objects and arrays -s.object({ name: s.string() }) // Object shape -s.array(s.string()) // Array of strings -s.record(s.string(), s.number()) // { [key: string]: number } - -// Optional and nullable -s.optional(s.string()) // string | undefined -s.nullable(s.string()) // string | null - -// Unions, literals, and enums -s.union(s.string(), s.number()) // string | number -s.literal('active') // Exact value -s.enum(['admin', 'user', 'guest']) // One of these values - -// Type coercion (useful for form inputs, query params) -s.coerce.string() // Convert to string via String(value) -s.coerce.number() // Convert to number via Number(value) -s.coerce.boolean() // Convert to boolean via Boolean(value) -s.coerce.date() // Convert to Date via new Date(value) -``` - -### Type Inference - -Extract TypeScript types from schemas: - -```typescript -import { s } from '@agentuity/schema'; - -const User = s.object({ - name: s.string(), - age: s.number(), - role: s.enum(['admin', 'user']), -}); - -// Extract the type -type User = s.infer; -// { name: string; age: number; role: 'admin' | 'user' } -``` - -### JSON Schema Generation - -Convert schemas to JSON Schema for use with LLM structured output: - -```typescript -import { s } from '@agentuity/schema'; - -const ResponseSchema = s.object({ - answer: s.string(), - confidence: s.number(), -}); - -// Generate JSON Schema -const jsonSchema = s.toJSONSchema(ResponseSchema); - -// Generate strict JSON Schema for LLM structured output -const strictSchema = s.toJSONSchema(ResponseSchema, { strict: true }); -``` - - -Use `{ strict: true }` when generating schemas for LLM structured output (e.g., OpenAI's `response_format`). Strict mode ensures the schema is compatible with model constraints and produces more reliable outputs. - - - -Use `@agentuity/schema` when you want a small validation layer for common object, array, union, enum, literal, and coercion cases. Use Zod, Valibot, or ArkType when you need deeper validation rules, transformations, metadata, or ecosystem integrations. - - -## Using Valibot - -### Installation - -```bash -bun add valibot -``` - -### Basic Schema - -```typescript -import { createAgent } from '@agentuity/runtime'; -import * as v from 'valibot'; - -const InputSchema = v.object({ - email: v.pipe(v.string(), v.email()), - age: v.pipe(v.number(), v.minValue(0), v.maxValue(120)), - role: v.picklist(['admin', 'user', 'guest']), -}); - -const OutputSchema = v.object({ - success: v.boolean(), - userId: v.string(), -}); - -const agent = createAgent('UserProcessor', { - schema: { - input: InputSchema, - output: OutputSchema, - }, - handler: async (ctx, input) => { - ctx.logger.info('Processing user', { email: input.email, role: input.role }); - return { - success: true, - userId: crypto.randomUUID(), - }; - }, -}); - -export default agent; -``` - -### Common Patterns - -```typescript -import * as v from 'valibot'; - -// Strings -v.string() // Any string -v.pipe(v.string(), v.minLength(5)) // Minimum length -v.pipe(v.string(), v.maxLength(100)) // Maximum length -v.pipe(v.string(), v.email()) // Email format -v.pipe(v.string(), v.url()) // URL format - -// Numbers -v.number() // Any number -v.pipe(v.number(), v.minValue(0)) // Minimum value -v.pipe(v.number(), v.maxValue(100)) // Maximum value -v.pipe(v.number(), v.integer()) // Integer only - -// Arrays and objects -v.array(v.string()) // Array of strings -v.object({ name: v.string() }) // Object shape - -// Optional and nullable -v.optional(v.string()) // string | undefined -v.nullable(v.string()) // string | null -v.nullish(v.string()) // string | null | undefined - -// Enums -v.picklist(['a', 'b', 'c']) // One of these values -v.literal('exact') // Exact value - -// Defaults -v.optional(v.string(), 'default') // With default value -``` - -## Using ArkType - -### Installation - -```bash -bun add arktype -``` - -### Basic Schema - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { type } from 'arktype'; - -const InputSchema = type({ - email: 'string.email', - age: '0 <= number <= 120', - role: '"admin"|"user"|"guest"', -}); - -const OutputSchema = type({ - success: 'boolean', - userId: 'string', -}); - -const agent = createAgent('UserProcessor', { - schema: { - input: InputSchema, - output: OutputSchema, - }, - handler: async (ctx, input) => { - ctx.logger.info('Processing user', { email: input.email, role: input.role }); - return { - success: true, - userId: crypto.randomUUID(), - }; - }, -}); - -export default agent; -``` - -### Common Patterns - -```typescript -import { type } from 'arktype'; - -// Strings -type('string') // Any string -type('string > 5') // Minimum length (greater than 5 chars) -type('string < 100') // Maximum length -type('string.email') // Email format -type('string.url') // URL format - -// Numbers -type('number') // Any number -type('number > 0') // Greater than 0 -type('number <= 100') // Less than or equal to 100 -type('number.integer') // Integer only -type('0 <= number <= 120') // Range (0 to 120) - -// Arrays and objects -type('string[]') // Array of strings -type({ name: 'string' }) // Object shape - -// Optional properties and unions -type({ 'nickname?': 'string' }) // Optional object property -type('string | null') // string | null - -// Enums and literals -type('"a"|"b"|"c"') // One of these values -type('"exact"') // Exact value - -// Nested objects -type({ - user: { - name: 'string', - email: 'string.email', - }, - tags: 'string[]?', -}) -``` - -## Migrating Between Libraries - -Schemas are interchangeable in `createAgent()`. The same agent structure works with any StandardSchema library: - -```typescript -// With @agentuity/schema (built-in) -import { s } from '@agentuity/schema'; -const schema = { input: s.object({ name: s.string() }) }; - -// With Zod -import { z } from 'zod'; -const schema = { input: z.object({ name: z.string() }) }; - -// With Valibot -import * as v from 'valibot'; -const schema = { input: v.object({ name: v.string() }) }; - -// With ArkType -import { type } from 'arktype'; -const schema = { input: type({ name: 'string' }) }; - -// All work the same way in createAgent() -createAgent('MyAgent', { schema, handler: async (ctx, input) => { ... } }); -``` - -## Next Steps - -- [Creating Agents](/agents/creating-agents): Full guide to agent creation with Zod examples -- [Using the AI SDK](/agents/ai-sdk-integration): Add LLM capabilities to your agents diff --git a/docs/src/web/content/agents/standalone-execution.mdx b/docs/src/web/content/agents/standalone-execution.mdx deleted file mode 100644 index bdf8ad43b..000000000 --- a/docs/src/web/content/agents/standalone-execution.mdx +++ /dev/null @@ -1,209 +0,0 @@ ---- -title: Running Agents Without HTTP -short_title: Standalone Execution -description: Execute agents programmatically for cron jobs, bots, CLI tools, and background workers ---- - -Sometimes your agent logic needs to run without an incoming HTTP request. `createAgentContext()` gives standalone code the same infrastructure that HTTP handlers get automatically: tracing, sessions, and storage access. - - -`createAgentContext()` works in standalone scripts, workers, and external backends. If you call it before `createApp()`, Agentuity starts a minimal standalone runtime automatically. Call `createApp()` first when you want your full app config, providers, or cloud-connected services loaded. - - -## Basic Usage - -```typescript -import { createAgentContext } from '@agentuity/runtime'; -import chatAgent from '@agent/chat/agent'; - -const ctx = createAgentContext(); -const result = await ctx.run(chatAgent, { message: 'Hello' }); -``` - -The `run()` method executes your agent with full infrastructure support: tracing, session management, and access to all storage services. - -For agents that don't require input: - -```typescript -const result = await ctx.run(statusAgent); -``` - - -`ctx.run(agent, input)` is a convenience wrapper around `ctx.invoke()`. Use `ctx.run()` for single agent calls; use `ctx.invoke()` when you need to run arbitrary functions or multiple agents in a single invocation. See [Using invoke() Directly](#using-invoke-directly) below. - - -## Options - -| Option | Type | Description | -|--------|------|-------------| -| `sessionId` | `string` | Custom session ID. Auto-generated from trace context if not provided | -| `trigger` | `string` | Trigger type for telemetry: `'discord'`, `'cron'`, `'websocket'`, `'manual'` | -| `parentContext` | `Context` | Parent OpenTelemetry context for distributed tracing | - -By default, each standalone invocation creates a fresh thread and restores or creates a session from the session ID. Pass `sessionId` when an external system needs a stable session identifier for a run. - -## External Cron Job Example - -For scheduled tasks managed outside of Agentuity: - -```typescript -import { createApp, createAgentContext } from '@agentuity/runtime'; -import cron from 'node-cron'; -import cleanupAgent from '@agent/cleanup/agent'; - -await createApp(); - -// Run cleanup every hour -cron.schedule('0 * * * *', async () => { - const ctx = createAgentContext({ trigger: 'cron' }); - await ctx.run(cleanupAgent, { task: 'expired-sessions' }); -}); -``` - - -For most scheduled tasks, use the [`cron()` middleware](/routes/cron) instead. It handles infrastructure automatically without needing `createAgentContext`. Use standalone execution only when you need external cron management. - - -## Multiple Agents in Sequence - -Run multiple agents in sequence with the same context: - -```typescript -const ctx = createAgentContext(); - -// First agent analyzes the input -const analysis = await ctx.run(analyzeAgent, { text: userInput }); - -// Second agent generates response based on analysis -const response = await ctx.run(respondAgent, { - analysis: analysis.summary, - sentiment: analysis.sentiment, -}); -``` - -Each `ctx.run()` call gets its own session and tracing span, while sharing the logger, tracer, and app state. - -## Reusing Contexts - -Create a context once and reuse it for multiple invocations: - -```typescript -const ctx = createAgentContext({ trigger: 'websocket' }); - -// Each run gets its own session and tracing span -websocket.on('message', async (data) => { - const result = await ctx.run(messageAgent, data); - websocket.send(result); -}); -``` - -## Using invoke() Directly - -`ctx.run()` is syntactic sugar over `ctx.invoke()`. Use `invoke()` when you need more control: - -- Run arbitrary async functions (not just agents) within agent context -- Execute multiple agents in a single invocation with a shared session and tracing span -- Set a custom OpenTelemetry span name - -### Running Arbitrary Functions - -```typescript -const ctx = createAgentContext({ trigger: 'cron' }); - -await ctx.invoke(async () => { - // Any async code runs with full agent infrastructure - const data = await ctx.kv.get('config', 'settings'); - await ctx.stream.create('audit', { - contentType: 'text/plain', - }); - ctx.logger.info('Cron task complete'); -}); -``` - -### Multiple Agents in One Invocation - -When agents share a session and tracing span: - -```typescript -const ctx = createAgentContext(); - -const result = await ctx.invoke(async () => { - const analysis = await analyzeAgent.run({ text: userInput }); - const response = await respondAgent.run({ - analysis: analysis.summary, - }); - return response; -}); -``` - -Both agent calls share the same OpenTelemetry span, session, and thread. Compare this with calling `ctx.run()` twice, where each call gets its own session and span. - -### Custom Span Names - -```typescript -const result = await ctx.invoke(() => agent.run(input), { spanName: 'daily-cleanup' }); -``` - -### invoke() Options - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `spanName` | `string` | `'agent-invocation'` | Span name for this invocation | - -## Invocation Lifecycle - -Each call to `ctx.run()` or `ctx.invoke()` follows this lifecycle: - -1. **Create tracing span**: An OpenTelemetry span is created within the parent context. Trace state includes project, org, and deployment IDs for distributed tracing. -2. **Create conversation state**: A fresh thread is created and the session is restored or created using the session ID. -3. **Fire session start event**: A `start` event is sent to the session event provider with the trigger type, session ID, thread ID, and deployment metadata. -4. **Execute function**: Your function runs within `AsyncLocalStorage` context, giving it access to `ctx.kv`, `ctx.stream`, `ctx.vector`, `ctx.logger`, and `waitUntil`. -5. **Handle background tasks**: If `ctx.waitUntil()` callbacks are pending, they continue after your function returns. -6. **Save session and thread**: Session and thread state are persisted via their providers. When `waitUntil()` tasks are pending, this happens after those tasks finish. -7. **Fire session complete event**: A `complete` event is sent with the final status code, agent IDs that participated, and any user data. When `waitUntil()` tasks are pending, this happens after those tasks finish. - -Errors during execution set the span status to `ERROR` and include the error message in the session complete event. Errors from `waitUntil()` tasks are recorded on the span and reported in the eventual session complete event. - -## What's Included - - -Each `ctx.run()` or `ctx.invoke()` call provides the same infrastructure as HTTP request handlers: - -- **Tracing**: OpenTelemetry spans with proper hierarchy -- **Sessions**: Automatic save/restore via providers -- **Threads**: Conversation state management -- **Storage**: Full access to `kv`, `stream`, `vector` -- **Background tasks**: `waitUntil` support for fire-and-forget work -- **Session events**: Start/complete events for observability - - -## Detecting Context - -Use `inAgentContext()` to check if code is running inside an agent handler with ambient context available: - -```typescript -import { inAgentContext, createAgentContext } from '@agentuity/runtime'; -import myAgent from '@agent/my-agent/agent'; - -async function processRequest(data: unknown) { - if (inAgentContext()) { - // Inside an agent handler, ambient context is available - // so agent.run() works directly without explicit context - return myAgent.run(data); - } - - // Outside agent handler, create context first - const ctx = createAgentContext(); - return ctx.run(myAgent, data); -} -``` - -This is useful for writing utility functions that work both inside agent handlers and in standalone scripts. - -To check if the Agentuity runtime is initialized (but not necessarily inside a handler), use `isInsideAgentRuntime()` instead. - -## Next Steps - -- [Calling Other Agents](/agents/calling-other-agents): Agent-to-agent communication patterns -- [Cron Routes](/routes/cron): Built-in scheduled task support -- [State Management](/agents/state-management): Thread and session state diff --git a/docs/src/web/content/agents/state-management.mdx b/docs/src/web/content/agents/state-management.mdx deleted file mode 100644 index 995910df3..000000000 --- a/docs/src/web/content/agents/state-management.mdx +++ /dev/null @@ -1,505 +0,0 @@ ---- -title: Managing State -short_title: State Management -description: Request and thread state for stateful agents ---- - -Agentuity gives you three closely related state surfaces: - -- **`ctx.state`** for temporary calculations within a single handler execution -- **`ctx.session.state`** for request-scoped session data -- **`ctx.thread.state`** for conversation context that persists across requests - - -Thread state uses async lazy-loading for performance. State is only fetched when first accessed, eliminating latency for requests that don't use thread state. All `ctx.thread.state` methods are async and require `await`. - - -## State Scopes - -| Scope | Lifetime | Cleared When | Access | Example Use Case | -|-------|----------|--------------|--------|----------| -| Request | Single handler execution | After response sent | `ctx.state` | Timing, temp calculations | -| Session | Single request | After response sent | `ctx.session.state` | Data needed in `session.completed` listeners | -| Thread | Multiple requests | Inactivity expiration or `destroy()` | `ctx.thread.state` | Conversation history | - - -Think of a *thread* as the conversation and a *session* as one request within that conversation. Each request creates a new session, but sessions within the same conversation share a thread. - - - -Routes have the same state access via `c.var.thread` and `c.var.session`. Use `c.var.thread.state` for conversation context and `c.var.session.state` for request-scoped data. See [HTTP Routes](/routes/http#request-context) for route examples. - - -### Quick Example - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; - -const agent = createAgent('StateDemo', { - schema: { - input: s.object({ message: s.string() }), - output: s.object({ - response: s.string(), - requestTime: s.number(), - messageCount: s.number(), - }), - }, - handler: async (ctx, input) => { - // REQUEST STATE: Cleared after this response (sync) - const startTime = Date.now(); - ctx.state.set('startTime', startTime); - - // THREAD STATE: Persists across requests (async, up to 1 hour) - const messages = (await ctx.thread.state.get('messages')) || []; - messages.push(input.message); - await ctx.thread.state.set('messages', messages); - - const requestTime = Date.now() - startTime; - - return { - response: `Received: ${input.message}`, - requestTime, - messageCount: messages.length, - }; - }, -}); - -export default agent; -``` - -## Request State - -Request state (`ctx.state`) holds temporary data within a single request. It's cleared automatically after the response is sent. - -```typescript -handler: async (ctx, input) => { - // Track timing - ctx.state.set('startTime', Date.now()); - - // Process request - const result = await processData(input); - - // Use the timing data - const duration = Date.now() - (ctx.state.get('startTime') as number); - ctx.logger.info('Request completed', { durationMs: duration }); - - return result; -} -``` - -**Use cases:** Request timing, temporary calculations, passing data between event listeners. - -## Thread State - -Thread state (`ctx.thread.state`) persists across multiple requests within a conversation, expiring after 1 hour of inactivity. Thread identity is managed automatically via cookies (or the `x-thread-id` header for API clients). - -### Conversation Memory - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { generateText } from 'ai'; -import { openai } from '@ai-sdk/openai'; -import * as v from 'valibot'; - -interface Message { - role: 'user' | 'assistant'; - content: string; -} - -const agent = createAgent('ConversationMemory', { - schema: { - input: v.object({ message: v.string() }), - output: v.string(), - }, - handler: async (ctx, input) => { - // Initialize on first request - if (!(await ctx.thread.state.has('messages'))) { - await ctx.thread.state.set('messages', []); - await ctx.thread.state.set('turnCount', 0); - } - - const messages = await ctx.thread.state.get('messages') || []; - const turnCount = await ctx.thread.state.get('turnCount') || 0; - - // Add user message - messages.push({ role: 'user', content: input.message }); - - // Generate response with conversation context - const { text } = await generateText({ - model: openai('gpt-5-mini'), - system: 'You are a helpful assistant. Reference previous messages when relevant.', - messages, - }); - - // Update thread state - messages.push({ role: 'assistant', content: text }); - await ctx.thread.state.set('messages', messages); - await ctx.thread.state.set('turnCount', turnCount + 1); - - ctx.logger.info('Conversation turn', { - threadId: ctx.thread.id, - turnCount: turnCount + 1, - }); - - return text; - }, -}); - -export default agent; -``` - -### Thread Properties and Methods - -```typescript -ctx.thread.id; // Thread ID (thrd_...) - -// All state methods are async -await ctx.thread.state.set('key', value); -await ctx.thread.state.get('key'); -await ctx.thread.state.has('key'); -await ctx.thread.state.delete('key'); -await ctx.thread.state.clear(); - -// Array operations with optional sliding window -await ctx.thread.state.push('messages', newMessage); -await ctx.thread.state.push('messages', newMessage, 100); // Keep last 100 - -// Bulk access (returns arrays, not iterators) -const keys = await ctx.thread.state.keys(); // string[] -const values = await ctx.thread.state.values(); // Message[] -const entries = await ctx.thread.state.entries(); // [string, Message][] -const count = await ctx.thread.state.size(); // number - -// State status -ctx.thread.state.loaded; // Has state been fetched? -ctx.thread.state.dirty; // Are there pending changes? - -// Reset the conversation -await ctx.thread.destroy(); -``` - -### Resetting a Conversation - -Call `ctx.thread.destroy()` to clear all thread state and start fresh: - -```typescript -handler: async (ctx, input) => { - if (input.command === 'reset') { - await ctx.thread.destroy(); - return 'Conversation reset. Thread state cleared.'; - } - - // Continue conversation -} -``` - -## ctx.state vs ctx.session.state - -Both `ctx.state` and `ctx.session.state` are request-scoped and reset after each request. The difference: - -- **`ctx.state`**: General request state, accessible in agent event listeners and other background hooks -- **`ctx.session.state`**: Accessible via `session` in completion event callbacks - -For most use cases, use `ctx.state`. Use `ctx.session.state` only when you need data in session completion events. See [Events & Lifecycle](/agents/events-lifecycle) for more on event handlers. - -```typescript -handler: async (ctx, input) => { - ctx.state.set('startTime', Date.now()); - ctx.session.state.set('requestType', 'chat'); - - ctx.session.addEventListener('completed', (eventName, session) => { - const requestType = session.state.get('requestType') as string | undefined; - ctx.logger.info('Session completed', { - requestType, - sessionId: session.id, - }); - }); - - return { ok: true, message: input.message }; -} -``` - - -Neither `ctx.state` nor `ctx.session.state` persist across requests. For data that should survive across requests, use `ctx.thread.state` (up to 1 hour) or [KV storage](/services/storage/key-value) (durable). - - -## Persisting to Storage - -In-memory state is lost on server restart. For durability, combine state management with KV storage: - -### Load → Cache → Save Pattern - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { streamText } from 'ai'; -import { openai } from '@ai-sdk/openai'; -import * as v from 'valibot'; - -type Message = { role: 'user' | 'assistant'; content: string }; - -const agent = createAgent('PersistentChat', { - schema: { - input: v.object({ message: v.string() }), - stream: true, - }, - handler: async (ctx, input) => { - const key = `chat_${ctx.thread.id}`; - let messages: Message[] = []; - - // Load from KV on first access in this thread - if (!(await ctx.thread.state.has('kvLoaded'))) { - const result = await ctx.kv.get('conversations', key); - if (result.exists) { - messages = result.data; - ctx.logger.info('Loaded conversation from KV', { messageCount: messages.length }); - } - await ctx.thread.state.set('messages', messages); - await ctx.thread.state.set('kvLoaded', true); - } else { - messages = await ctx.thread.state.get('messages') || []; - } - - // Add user message - messages.push({ role: 'user', content: input.message }); - - // Stream response - const result = streamText({ - model: openai('gpt-5-mini'), - messages, - }); - - // Save in background (non-blocking) - ctx.waitUntil(async () => { - const fullText = await result.text; - messages.push({ role: 'assistant', content: fullText }); - - // Keep last 20 messages to bound state size - const recentMessages = messages.slice(-20); - await ctx.thread.state.set('messages', recentMessages); - - // Persist to KV - await ctx.kv.set('conversations', key, recentMessages, { - ttl: 86400, // 24 hours - }); - }); - - return result.textStream; - }, -}); - -export default agent; -``` - -**Key points:** -- Load from KV once per thread, cache in thread state -- Use `ctx.waitUntil()` for non-blocking saves -- Bound state size to prevent unbounded growth - - -Thread state supports write-only operations without loading existing state. If you only call `set()`, `delete()`, or `push()` without any reads, the SDK batches these as a merge operation, avoiding the latency of fetching existing state. - - -## Thread Lifecycle - -Use `ctx.thread.destroy()` when you want to end a conversation immediately. That fires the thread's `destroyed` event and clears its persisted state. - -```typescript -ctx.thread.addEventListener('destroyed', async (eventName, thread) => { - ctx.logger.info('Thread destroyed', { threadId: thread.id }); -}); -``` - -Thread data also expires after inactivity based on the active thread provider. Treat the `destroyed` listener as the explicit teardown hook you control with `ctx.thread.destroy()`, not as the only place to archive important data. - -Similarly, track when sessions complete with `ctx.session.addEventListener('completed', ...)`. - -For app-level monitoring of all threads and sessions, see [Events & Lifecycle](/agents/events-lifecycle). - -## Thread vs Session IDs - -| ID | Format | Lifetime | Purpose | -|----|--------|----------|---------| -| Thread ID | `thrd_` | Up to 1 hour (shared across requests) | Group related requests into conversations | -| Session ID | `sess_` | Single request (unique per call) | Request tracing and analytics | - -```typescript -handler: async (ctx, input) => { - ctx.logger.info('Request received', { - threadId: ctx.thread.id, // Same across conversation - sessionId: ctx.sessionId, // Unique per request - }); - - return { threadId: ctx.thread.id, sessionId: ctx.sessionId }; -} -``` - -## Advanced: Custom Thread IDs - -By default, thread IDs are generated automatically and managed via signed cookies. For advanced use cases, you can provide custom thread ID logic using a `ThreadIDProvider`. - -**Use cases:** -- Integrating with external identity systems -- Multi-tenant applications where threads should be scoped to users -- Custom conversation management tied to your domain model - -### ThreadIDProvider Interface - -```typescript -import type { Context } from 'hono'; -import type { Env, AppState } from '@agentuity/runtime'; - -interface ThreadIDProvider { - getThreadId(appState: AppState, ctx: Context): string | Promise; -} -``` - -### Thread ID Format Requirements - -Custom thread IDs must follow these rules: - -| Requirement | Value | -|-------------|-------| -| Prefix | Must start with `thrd_` | -| Length | 32-64 characters total | -| Characters | After prefix: `[a-zA-Z0-9]` only | - -```typescript -// Valid thread IDs -'thrd_abc123def456789012345678901' // 32 chars - minimum -'thrd_' + 'a'.repeat(59) // 64 chars - maximum - -// Invalid thread IDs -'thrd_abc' // Too short -'thrd_abc-def-123' // Dashes not allowed -'thread_abc123' // Wrong prefix -``` - -### Custom Provider Example - -Create a provider that generates deterministic thread IDs based on authenticated users: - -```typescript -import { createApp, getThreadProvider } from '@agentuity/runtime'; -import type { ThreadIDProvider } from '@agentuity/runtime'; - -const userThreadProvider: ThreadIDProvider = { - getThreadId: async (appState, ctx) => { - const userId = ctx.req.header('x-user-id'); - - if (userId) { - // Create deterministic thread ID from user ID - const data = new TextEncoder().encode(userId); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hex = Array.from(new Uint8Array(hashBuffer)) - .map(b => b.toString(16).padStart(2, '0')) - .join('') - .slice(0, 27); - return `thrd_${hex}`; - } - - // Fall back to random ID for unauthenticated requests - const arr = new Uint8Array(16); - crypto.getRandomValues(arr); - return `thrd_${Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('')}`; - } -}; - -const app = await createApp({}); - -getThreadProvider().setThreadIDProvider(userThreadProvider); - -export default app; -``` - -`createApp()` initializes the runtime services, so call `getThreadProvider()` after `createApp()` returns and before exporting the app. - -### Default Thread ID Behavior - -The built-in `DefaultThreadIDProvider` handles thread IDs automatically: - -1. **Check header**: Looks for signed `x-thread-id` header -2. **Check cookie**: Falls back to signed `atid` cookie -3. **Generate new**: Creates a new random thread ID if neither exists -4. **Persist**: Sets both the response header and cookie for future requests - -The signing uses `AGENTUITY_SDK_KEY` (or defaults to `'agentuity'` in development) with HMAC SHA-256 to prevent tampering. - -### Client-Side Thread Persistence - -When building frontends, you can maintain thread continuity by storing and sending the thread ID: - -```typescript -// Client-side: store thread ID from response header -const response = await fetch('/api/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }), -}); - -const threadId = response.headers.get('x-thread-id'); - -if (threadId) { - localStorage.setItem('threadId', threadId); -} - -// Subsequent requests: send thread ID header -const storedThreadId = localStorage.getItem('threadId'); -const headers = new Headers({ 'Content-Type': 'application/json' }); - -if (storedThreadId) { - headers.set('x-thread-id', storedThreadId); -} - -const nextResponse = await fetch('/api/chat', { - method: 'POST', - headers, - body: JSON.stringify({ message: nextMessage }), -}); -``` - - -The `x-thread-id` header must include the signature (format: `threadId;signature`). Use the exact value from the response header. Unsigned or tampered thread IDs are rejected. - - -## State Size Limits - - -Thread and session state are limited to **1MB** after JSON serialization. If serialized state exceeds that limit, the runtime does not persist it. If serialization fails entirely, the runtime logs an error and skips persistence for that value. - - -To stay within limits: -- Store large data in [KV storage](/services/storage/key-value) instead of state -- Keep only recent messages (e.g., last 20-50) -- Store IDs or references rather than full objects - -## Best Practices - -- **Use the right scope**: `ctx.state` for request-scoped data, `ctx.thread.state` for conversations -- **Keep state bounded**: Limit conversation history (e.g., last 20-50 messages) -- **Persist important data**: Don't rely on state for data that must survive restarts -- **Clean up resources**: Use `destroyed` event to save or archive data -- **Cache strategically**: Load from KV once, cache in thread state, save on completion -- **Watch state size**: Stay under 1MB to avoid skipped persistence -- **Use `push()` for arrays**: The `push()` method with `maxRecords` handles sliding windows efficiently - -```typescript -// Good: Use push() with maxRecords for bounded history -await ctx.thread.state.push('messages', newMessage, 50); // Keeps last 50 - -// Alternative: Manual bounding when you need the full array -const messages = await ctx.thread.state.get('messages') || []; -if (messages.length > 50) { - const archived = messages.slice(0, -50); - ctx.waitUntil(async () => { - await ctx.kv.set('archives', `${ctx.thread.id}_${Date.now()}`, archived); - }); - await ctx.thread.state.set('messages', messages.slice(-50)); -} -``` - -## Next Steps - -- [Key-Value Storage](/services/storage/key-value): Durable data persistence with namespaces and TTL -- [Calling Other Agents](/agents/calling-other-agents): Share state between agents in workflows -- [Events & Lifecycle](/agents/events-lifecycle): Monitor agent execution and cleanup diff --git a/docs/src/web/content/agents/streaming-responses.mdx b/docs/src/web/content/agents/streaming-responses.mdx deleted file mode 100644 index 2d3e3155e..000000000 --- a/docs/src/web/content/agents/streaming-responses.mdx +++ /dev/null @@ -1,273 +0,0 @@ ---- -title: Returning Streaming Responses -short_title: Streaming Responses -description: Return real-time LLM output with streaming agents ---- - -Show LLM output as it's generated instead of waiting for the full response. Streaming reduces perceived latency and creates a more responsive experience. - -## Streaming Types - -Agentuity supports two streaming patterns: - -### Ephemeral Streaming - -Returns a `ReadableStream` directly to the HTTP client. Data flows through and is not stored. Use this for real-time chat responses. - -```typescript -// In src/api/index.ts -import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; -import chatAgent from '@agent/chat'; - -const api = new Hono() - .post('/chat', chatAgent.validator(), async (c) => { - const body = c.req.valid('json'); - return c.body(await chatAgent.run(body)); - }); - -export default api; -``` - -### Persistent Streaming - -Uses `ctx.stream.create()` to create stored streams with public URLs. Data persists and can be accessed after the connection closes. Use this for batch processing, exports, or content that needs to be accessed later. - -```typescript -// In agent.ts -const stream = await ctx.stream.create('my-export', { - contentType: 'text/csv', -}); -await stream.write('data'); -await stream.close(); -``` - -For stored streams with public URLs, see the [Storage documentation](/services/storage/durable-streams). - - -Streaming agent responses require both: `schema.stream: true` in your agent and a route that returns the resulting stream, usually with `return c.body(await agent.run(data))`. Use `agent.validator()` as usual. Streaming agents skip output validation automatically. - - -## Basic Streaming - -Enable streaming by setting `stream: true` in your schema and returning a `textStream`: - - -The `textStream` from AI SDK's `streamText()` works directly with Agentuity. Return it from your handler, then return the resulting stream from your route without additional processing. - - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { streamText } from 'ai'; -import { anthropic } from '@ai-sdk/anthropic'; -import { s } from '@agentuity/schema'; - -const agent = createAgent('ChatStream', { - schema: { - input: s.object({ message: s.string() }), - stream: true, - }, - handler: async (ctx, input) => { - const { textStream } = streamText({ - model: anthropic('claude-sonnet-4-6'), - prompt: input.message, - }); - - return textStream; - }, -}); - -export default agent; -``` - -## Route Configuration - -For streaming agents, validate the request, run the agent, and return the resulting stream: - -```typescript -// src/api/index.ts -import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; -import chatAgent from '@agent/chat'; - -const api = new Hono() - .post('/chat', chatAgent.validator(), async (c) => { - const body = c.req.valid('json'); - return c.body(await chatAgent.run(body)); - }); - -export type ApiRouter = typeof api; - -export default api; -``` - -Use `c.body(await agent.run(data))` when the agent already returns a stream. Use `stream()` when the route itself creates the `ReadableStream`. - - -Use `stream()` when the route itself creates the `ReadableStream`. When you are forwarding a streaming agent response, return the agent stream directly with `c.body(...)`. For non-agent routes that use `validator()` with an output schema, pass `stream: true` to skip output validation on the stream. - - -## Consuming Streams - -### With Fetch API - -Read the stream using the Fetch API: - -```typescript -const response = await fetch('http://localhost:3500/api/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message: 'Tell me a story' }), -}); - -if (!response.ok) { - throw new Error(`Streaming request failed with ${response.status}`); -} - -const reader = response.body?.getReader(); -const decoder = new TextDecoder(); - -while (reader) { - const { done, value } = await reader.read(); - if (done) break; - - const text = decoder.decode(value); - // Process each chunk as it arrives - appendToUI(text); -} -``` - -### With React - -Use `hc()` when you want typed request bodies and direct access to the `Response` stream: - -```tsx -import { hc } from 'hono/client'; -import { useState } from 'react'; -import type { ApiRouter } from '../api'; - -const client = hc('/api'); - -function Chat() { - const [data, setData] = useState(''); - const [isLoading, setIsLoading] = useState(false); - - const handleSubmit = async (message: string) => { - setIsLoading(true); - setData(''); - - try { - const res = await client.chat.$post({ json: { message } }); - if (!res.ok) { - throw new Error(`Streaming request failed with ${res.status}`); - } - - const reader = res.body?.getReader(); - const decoder = new TextDecoder(); - - if (!reader) return; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - setData((prev) => prev + decoder.decode(value, { stream: true })); - } - } finally { - setIsLoading(false); - } - }; - - return ( -
- {isLoading &&

Generating...

} - {data &&

{data}

} - -
- ); -} -``` - -For type-safe API calls, see [RPC Client](/frontend/rpc-client). - -## Streaming with System Prompts - -Add context to streaming responses: - -```typescript -handler: async (ctx, input) => { - const { textStream } = streamText({ - model: anthropic('claude-sonnet-4-6'), - system: 'You are a helpful assistant. Be concise.', - messages: [ - { role: 'user', content: input.message }, - ], - }); - - return textStream; -} -``` - -## Streaming with Conversation History - -Combine streaming with thread state for multi-turn conversations: - -```typescript -handler: async (ctx, input) => { - // Get existing messages from thread state (async) - const messages = (await ctx.thread.state.get('messages')) || []; - - // Add new user message - messages.push({ role: 'user', content: input.message }); - - const { textStream, text } = streamText({ - model: anthropic('claude-sonnet-4-6'), - messages, - }); - - // Save assistant response after streaming completes - ctx.waitUntil(async () => { - const fullText = await text; - messages.push({ role: 'assistant', content: fullText }); - await ctx.thread.state.set('messages', messages); - }); - - return textStream; -} -``` - - -Use `ctx.waitUntil()` to save conversation history without blocking the stream. The response starts immediately while state updates happen in the background. - - -## When to Stream - -| Scenario | Recommendation | -|----------|----------------| -| Chat interfaces | Stream for better UX | -| Long-form content | Stream to show progress | -| Quick classifications | Buffer (faster overall, consider Groq for speed) | -| Structured data | Buffer (use AI SDK `Output.object()`) | - -## Error Handling - -Handle streaming errors with the `onError` callback: - -```typescript -const { textStream } = streamText({ - model: anthropic('claude-sonnet-4-6'), - prompt: input.message, - onError({ error }) { - ctx.logger.error('Stream error', { error }); - }, -}); -``` - - -Errors in streaming are part of the stream, not thrown exceptions. Always provide an `onError` callback. - - -## Next Steps - -- [Using the AI SDK](/agents/ai-sdk-integration): Structured output and non-streaming responses -- [State Management](/agents/state-management): Multi-turn conversations with memory -- [Server-Sent Events](/routes/sse): Server-push updates without polling diff --git a/docs/src/web/content/agents/when-to-use.mdx b/docs/src/web/content/agents/when-to-use.mdx deleted file mode 100644 index c06f6c017..000000000 --- a/docs/src/web/content/agents/when-to-use.mdx +++ /dev/null @@ -1,184 +0,0 @@ ---- -title: When to Use Agents vs Routes -short_title: When to Use -description: When to create an agent vs handling requests directly in routes ---- - -Both agents and routes live in your Agentuity project. Routes handle HTTP requests directly. Agents add schema validation, lifecycle hooks, and typed inter-agent calls. - -## Quick Decision Guide - -| Create an agent when you need... | Handle directly in a route when... | -|-----------------------------------|------------------------------------| -| Input and output schema validation | Health checks, status endpoints | -| Post-response quality checks | Webhook signature verification | -| Agent-to-agent calling with type safety | Simple CRUD operations | -| Lifecycle events (`started`, `completed`, `errored`) | Streaming protocols (WebSocket, SSE) | -| Reusable processing you want to call from routes, jobs, or workers | Cron-triggered cleanup jobs | - -## What's the Same - -Agents and routes share the same built-in platform services. These services are available through `ctx.*` in an agent and `c.var.*` in a route: - -| Service | In an agent (`ctx`) | In a route (`c.var`) | -|---------|---------------------|----------------------| -| Key-Value storage | `ctx.kv` | `c.var.kv` | -| Vector search | `ctx.vector` | `c.var.vector` | -| Durable streams | `ctx.stream` | `c.var.stream` | -| Logger | `ctx.logger` | `c.var.logger` | -| Tracer | `ctx.tracer` | `c.var.tracer` | -| Queue | `ctx.queue` | `c.var.queue` | -| Email | `ctx.email` | `c.var.email` | -| Sandbox | `ctx.sandbox` | `c.var.sandbox` | -| Schedule | `ctx.schedule` | `c.var.schedule` | -| Task | `ctx.task` | `c.var.task` | - - -Database and object storage use Bun's native `sql` and `s3` APIs instead of `ctx.*` or `c.var.*`. - - - -`ctx.kv.set('bucket', 'key', value)` in an agent does the same thing as `c.var.kv.set('bucket', 'key', value)` in a route. If your logic only needs these services, a route works fine on its own. - - -## What's Different - -These capabilities are **agent-only**: - -- **Schema validation**: Define input and output schemas. Use `agent.validator()` as route middleware for automatic request validation -- **Quality checks**: Add post-response validation when you need to score or review outputs -- **Lifecycle events**: Hook into `started`, `completed`, and `errored` events for monitoring -- **Agent-to-agent calling**: Call other agents with full type safety via `agent.run()` -- **Reusable logic across entry points**: Run the same processing flow from routes, background jobs, or standalone scripts - -If you need any of these, create an agent. - -Routes can still use thread and session context through `c.var.thread` and `c.var.session` when you need stateful request handling. - -## How They Work Together - -Routes are the HTTP layer, agents are the processing layer. Most applications use both: - -``` -Request → Route (auth, validation) → Agent (processing) → Route (response formatting) -``` - -A route receives the HTTP request, applies middleware (authentication, rate limiting), then delegates to an agent for structured processing. The agent returns typed output, and the route formats the HTTP response. - -## Basic Usage - -### Route-Only: Health Check - -A health endpoint doesn't need an agent: - -```typescript -import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; - -const router = new Hono(); - -// Replace with your actual database health check -const checkDatabase = async () => true; - -router.get('/health', async (c) => { - const dbHealthy = await checkDatabase(); - return c.json({ status: dbHealthy ? 'healthy' : 'degraded' }); -}); - -export default router; -``` - -### Route + Agent: Chat Endpoint - -For structured processing, create an agent and call it from your route: - -```typescript -import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; -import chat from '@agent/chat/agent'; - -const router = new Hono() - .post('/chat', chat.validator(), async (c) => { - const data = c.req.valid('json'); // Fully typed from agent schema - const result = await chat.run(data); - return c.json(result); - }); - -export default router; -``` - -The agent defines the schema, validates input, runs the handler, and fires lifecycle events. The route just wires it to HTTP. - -### Webhook: Route Validates, Agent Processes - -Webhooks verify signatures in the route, then hand off to an agent in the background: - -```typescript -import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; -import paymentProcessor from '@agent/payment-processor/agent'; - -// Replace with your actual Stripe signature verification -const verifyStripeSignature = (body: string, sig: string | undefined) => true; - -const router = new Hono() - .post('/webhooks/stripe', async (c) => { - const signature = c.req.header('stripe-signature'); - const rawBody = await c.req.text(); - - if (!verifyStripeSignature(rawBody, signature)) { - return c.json({ error: 'Invalid signature' }, 401); - } - - // Respond fast, process in background - c.waitUntil(async () => { - await paymentProcessor.run(JSON.parse(rawBody)); - }); - - return c.json({ received: true }); - }); - -export default router; -``` - - -`c.waitUntil()` lets you respond immediately while the agent processes in the background. Webhook providers expect responses under 3 seconds. - - -### Agent-Only: Standalone Execution - -Agents can also run outside of HTTP requests, triggered by other agents, cron jobs, or queue consumers: - -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; - -export default createAgent('daily-report', { - schema: { - input: s.object({ date: s.string() }), - output: s.object({ report: s.string(), metrics: s.number() }), - }, - handler: async (ctx, input) => { - const data = await ctx.kv.get('metrics', input.date); - const summary = `Report for ${input.date}: ${JSON.stringify(data)}`; - return { report: summary, metrics: 42 }; - }, -}); -``` - -See [Standalone Execution](/agents/standalone-execution) for running agents from cron, queues, and other agents. - -## Best Practices - -- Handle authentication and rate limiting in routes, not agents -- Use `agent.validator()` as route middleware for type-safe request validation -- Keep agents focused on processing logic, let routes handle HTTP concerns -- Use `c.waitUntil()` in routes for background work when the client doesn't need the result -- Prefer agents when you need schema validation, post-response quality checks, or agent-to-agent calling - -## Next Steps - -- [Creating Agents](/agents/creating-agents): Build agents with schemas and focused handlers -- [HTTP Routes](/routes/http): Handle requests that don't need agent processing -- [Calling Agents from Routes](/routes/calling-agents): Import and invoke agents with validation -- [Middleware](/routes/middleware): Add auth and rate limiting in front of agents diff --git a/docs/src/web/content/build/agents.mdx b/docs/src/web/content/build/agents.mdx new file mode 100644 index 000000000..7cb28f25f --- /dev/null +++ b/docs/src/web/content/build/agents.mdx @@ -0,0 +1,242 @@ +--- +title: Build Agents +description: Structure model-backed workflows as plain server functions called from framework routes +--- + +When a request needs a decision loop, such as validating input, reading context, calling a model, and storing the result, that work belongs in a plain server-side function, not spread across the route handler. The route stays thin; the agent function owns the logic. + + +Legacy runtime apps use `createAgent()` and a runtime-managed `src/agent/` folder. The framework app pattern documented here keeps agent work in ordinary server code. See the [legacy runtime docs](https://v2.agentuity.dev) if your app still uses `createAgent()`. + + +## The Minimal Shape + +Start with a plain typed function. It accepts validated input, returns typed output, and does not call `createAgent()`. + +```typescript +import { KeyValueClient } from '@agentuity/keyvalue'; +import { openai } from '@ai-sdk/openai'; +import { generateText, Output } from 'ai'; +import { z } from 'zod'; + +const outputSchema = z.object({ + priority: z.enum(['low', 'medium', 'high']).describe('Urgency level'), + summary: z.string().describe('One-sentence summary of the issue'), +}); + +type TriageResult = z.infer; + +const kv = new KeyValueClient(); + +export async function triageMessage( + customerId: string, + message: string +): Promise { + const previous = await kv.get('support-triage', customerId); + + const { output } = await generateText({ // [!code highlight] + model: openai('gpt-5.5'), + output: Output.object({ schema: outputSchema }), // [!code highlight] + system: 'Classify support messages for a product engineering team.', + prompt: [ + `Customer message: ${message}`, + previous.exists ? `Previous summary: ${previous.data.summary}` : '', + ] + .filter(Boolean) + .join('\n\n'), + }); + + await kv.set('support-triage', customerId, output, { // [!code highlight] + ttl: 60 * 60 * 24 * 30, // 30 days + }); + + return output; +} +``` + +Then keep the route thin: + +```typescript title="app/api/triage/route.ts" +import { triageMessage } from '@/lib/triage'; +import { z } from 'zod'; + +const inputSchema = z.object({ + customerId: z.string(), + message: z.string(), +}); + +export async function POST(request: Request): Promise { + const body: unknown = await request.json(); + const { customerId, message } = inputSchema.parse(body); // [!code highlight] + const result = await triageMessage(customerId, message); // [!code highlight] + return Response.json(result); +} +``` + +## Complete Self-Contained Example + +This is the full file version: one module, schemas, agent function, and route handler together. Use this shape for smaller features before splitting. + +```bash +bun add ai @ai-sdk/openai @agentuity/keyvalue@alpha zod +``` + +```typescript title="app/api/triage/route.ts" +import { KeyValueClient } from '@agentuity/keyvalue'; +import { openai } from '@ai-sdk/openai'; +import { generateText, Output } from 'ai'; +import { z } from 'zod'; + +// Schemas defined once, reused for Output.object and type inference +const inputSchema = z.object({ + customerId: z.string(), + message: z.string(), +}); + +const outputSchema = z.object({ + priority: z.enum(['low', 'medium', 'high']).describe('Urgency level'), + summary: z.string().describe('One-sentence summary of the issue'), + nextAction: z.string().describe('Recommended next step for the team'), +}); + +type TriageInput = z.infer; +type TriageResult = z.infer; + +// Module-level client: initialized once, reused across requests +const kv = new KeyValueClient(); + +async function runSupportTriage(input: TriageInput): Promise { + const previous = await kv.get('support-triage', input.customerId); + + const { output } = await generateText({ + model: openai('gpt-5.5'), + output: Output.object({ schema: outputSchema }), + system: 'Classify support messages for a product engineering team.', + prompt: [ + `Customer message: ${input.message}`, + previous.exists ? `Previous summary: ${previous.data.summary}` : '', + ] + .filter(Boolean) + .join('\n\n'), + }); + + await kv.set('support-triage', input.customerId, output, { + ttl: 60 * 60 * 24 * 30, + }); + + return output; +} + +export async function POST(request: Request): Promise { + const body: unknown = await request.json(); + const input = inputSchema.parse(body); + const result = await runSupportTriage(input); + return Response.json(result); +} +``` + +What each layer does: + +- The route owns HTTP parsing and response formatting +- `runSupportTriage` owns the model call, context read, and state write +- `KeyValueClient` stores context by namespace (`'support-triage'`) and key (customer ID) +- `Output.object({ schema })` validates the model response before storage; `previous.exists` is the type-safe way to access `previous.data` +- Zod's `.describe()` hints on enum and string fields improve model output consistency + +## Split the Route from the Work + +Once the function is stable, move it to a shared module. The same function can then run from a route, a queue consumer, a schedule, or a test, without duplicating any logic. + +```typescript title="lib/triage.ts" +export async function triageMessage(body: unknown): Promise { + const input = inputSchema.parse(body); // validates at the boundary + return runSupportTriage(input); +} +``` + +```typescript title="app/api/triage/route.ts" +import { triageMessage } from '@/lib/triage'; + +export async function POST(request: Request): Promise { + const body: unknown = await request.json(); + const result = await triageMessage(body); + return Response.json(result); +} +``` + +Unit tests call `triageMessage()` directly with plain objects, so you do not need to build a `Request`. + +## Framework vs. Legacy Runtime App Boundaries + +| Concern | Framework app | Legacy runtime app | +|---|---|---| +| Agent entry point | Plain `async function` | `createAgent()` | +| File location | Anywhere in your project | `src/agent/*` | +| Storage access | `new KeyValueClient()` or `c.var.kv` in Hono | `ctx.kv` | +| Calling an agent | Call the function directly | `agent.run(input)` | +| Route ownership | You own it | Runtime-managed | + +## Using Hono Middleware for Shared Clients + +In a Hono app, `@agentuity/hono` initializes all service clients once and injects them via `c.var`. Use this when most routes in the app need the same clients or logger. + +```bash +bun add hono @agentuity/hono@alpha +``` + +```typescript title="src/index.ts" +import { Hono } from 'hono'; +import { agentuity } from '@agentuity/hono'; +import { s } from '@agentuity/schema'; +import type { Services, Logger } from '@agentuity/hono'; + +type Variables = Pick & { logger: Logger }; + +const app = new Hono<{ Variables: Variables }>(); + +app.use('*', agentuity()); // initializes kv, logger, and other clients once // [!code highlight] + +app.post('/api/triage', async (c) => { + const body: unknown = await c.req.json(); + const input = s.object({ customerId: s.string(), message: s.string() }).parse(body); + + const previous = await c.var.kv.get<{ summary: string }>('support-triage', input.customerId); // [!code highlight] + c.var.logger.info('triage requested', { customerId: input.customerId }); + + return c.json({ + previousSummary: previous.exists ? previous.data.summary : null, + }); +}); + +export default app; +``` + +Use `c.var.logger` only inside Hono route handlers. For code outside a Hono context, import `logger` from `@agentuity/telemetry` directly. + +Direct clients (`new KeyValueClient()`) are the portable default and work in Next.js, TanStack Start, SvelteKit, scripts, and workers. Hono middleware is the better choice when you are already in a Hono app and want a single initialization point. + +## When to Use This Pattern + +| Use this pattern for | Consider something else when | +|---|---| +| Classifying, routing, summarizing, drafting, or reviewing input | The work is pure CRUD with no model decision | +| Workflows that read and write durable context between requests | The state belongs inside a database transaction | +| Logic that may later run from queues, schedules, or scripts | You need a long-running sandbox or Coder session | +| Features where the model call should be unit-testable | The logic is too simple to warrant a separate function | + +## Best Practices + +- **Separate parsing from work.** Parse and validate at the route boundary. Pass typed values into the agent function, not raw `Request` bodies. +- **Instantiate clients at module scope.** `new KeyValueClient()` at the top of the file, not inside the handler. One instance per process. +- **Check `result.exists` before reading `result.data`.** `KeyValueClient.get` returns a discriminated union: `data` is only present when `exists` is `true`. +- **Use Zod `.describe()` on output schema fields.** Field descriptions are passed to the model and improve structured output consistency for enums and formatted strings. +- **Keep agent functions framework-free.** Don't import Hono or Next.js internals into the function that does model work. That keeps it testable and portable. +- **Log with the right logger.** `c.var.logger` in Hono route handlers, `logger` from `@agentuity/telemetry` in shared modules and scripts. Never `console.log`. + +## Next Steps + +- [Chat and Streaming](/build/chat-and-streaming): return model output as it is generated, rather than waiting for a complete response +- [Tool Calling](/build/tool-calling): let a model call bounded app functions and act on results +- [State and Memory](/build/state-and-memory): store and retrieve conversation context across requests +- [Background Work](/build/background-work): move slow model or export work out of the request handler +- [Key-Value Storage](/services/storage/key-value): full reference for `KeyValueClient` namespaces, TTLs, and search diff --git a/docs/src/web/content/build/background-work.mdx b/docs/src/web/content/build/background-work.mdx new file mode 100644 index 000000000..f45f00ee3 --- /dev/null +++ b/docs/src/web/content/build/background-work.mdx @@ -0,0 +1,239 @@ +--- +title: Background Work +description: Use queues, status records, and durable streams for work that should outlive a request +--- + +Use background work when a request starts something slow: report generation, webhook fan-out, email preparation, model evaluation, or file processing. The pattern is: the route enqueues the job and returns a handle; the worker route does the work and writes output to a durable stream; KV holds a status record the client can poll at any time. + +```bash +bun add hono @agentuity/queue@alpha @agentuity/keyvalue@alpha @agentuity/stream@alpha @agentuity/schema@alpha +``` + +## Set Up the Queue + +Create the worker queue once, outside the request path. The `--visibility-timeout` controls how long a message stays invisible after delivery before it is retried if your worker never responds. + +```bash +agentuity cloud queue create worker \ + --name report-jobs \ + --max-retries 3 \ + --visibility-timeout 120 +``` + +After your app has a public URL, add an HTTP destination so the platform calls your worker route automatically on every new message. Use a tunnel like [ngrok](https://ngrok.com) or [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/local-management/) to expose your local dev server, or use the deployed app URL. + +```bash +agentuity cloud queue destinations create report-jobs \ + --type http \ + --name report-worker \ + --url https:///api/workers/report-jobs +``` + + +`queue.publish()` stores the message. The platform only calls your worker route when a queue destination is configured and points at a reachable public URL. `127.0.0.1` and `localhost` are not reachable from the queue; use a tunnel for local testing or the deployed URL. + + +## Status Type + +Define the discriminated union for the status record first. Both the request route and the worker route will write it; the status route and the client will read it. + +```typescript title="src/index.ts" +import { Hono } from 'hono'; +import { QueueClient } from '@agentuity/queue'; +import { KeyValueClient } from '@agentuity/keyvalue'; +import { StreamClient } from '@agentuity/stream'; +import { s } from '@agentuity/schema'; + +type ReportStatus = + | { readonly kind: 'queued'; readonly messageId: string } + | { readonly kind: 'processing' } + | { readonly kind: 'done'; readonly streamUrl: string } + | { readonly kind: 'failed'; readonly message: string }; + +const requestSchema = s.object({ + accountId: s.string(), + reportMonth: s.string(), +}); + +const jobSchema = s.object({ + jobId: s.string(), + accountId: s.string(), + reportMonth: s.string(), +}); + +type ReportJob = s.infer; + +const queue = new QueueClient(); +const kv = new KeyValueClient(); +const streams = new StreamClient(); +const app = new Hono(); +``` + +## Request Route + +The request route validates input, publishes the job, writes an initial status record, and returns `202 Accepted` with a status URL the client can poll. + +```typescript title="src/index.ts" +app.post('/api/reports', async (c) => { + const body: unknown = await c.req.json(); + const input = requestSchema.parse(body); + const jobId = crypto.randomUUID(); + + const job: ReportJob = { + jobId, + accountId: input.accountId, + reportMonth: input.reportMonth, + }; + + // idempotencyKey ties the message to this jobId so retries don't duplicate // [!code highlight] + const message = await queue.publish('report-jobs', job, { + idempotencyKey: jobId, // [!code highlight] + }); + + await kv.set('report-status', jobId, { // [!code highlight] + kind: 'queued', + messageId: message.id, + }); + + return c.json({ jobId, statusUrl: `/api/reports/${jobId}` }, 202); +}); +``` + +`queue.publish()` returns `{ id, offset, publishedAt }`. The `id` is the message ID stored in the status record for tracing. + +## Status Route + +The status route reads the KV record written by the request route and later updated by the worker. Keep it thin. + +```typescript title="src/index.ts" +app.get('/api/reports/:jobId', async (c) => { + const result = await kv.get('report-status', c.req.param('jobId')); + + if (!result.exists) { + return c.json({ error: 'Report job not found' }, 404); + } + + return c.json(result.data); +}); +``` + +## Worker Route + +The worker route receives the queue message payload from the HTTP destination, updates the status record, writes output to a durable stream, and marks the job done. + +```typescript title="src/index.ts" +async function writeReport(job: ReportJob): Promise { + const stream = await streams.create('monthly-reports', { + contentType: 'text/csv', + metadata: { + accountId: job.accountId, + reportMonth: job.reportMonth, + }, + ttl: 60 * 60 * 24 * 90, // keep for 90 days + }); + + try { + await stream.write('account_id,report_month,total\n'); + await stream.write(`${job.accountId},${job.reportMonth},42817\n`); + } finally { + await stream.close(); // must always close to finalize // [!code highlight] + } + + return stream.url; +} + +app.post('/api/workers/report-jobs', async (c) => { + const body: unknown = await c.req.json(); + const job = jobSchema.parse(body); + + await kv.set('report-status', job.jobId, { kind: 'processing' }); + + try { + const streamUrl = await writeReport(job); + await kv.set('report-status', job.jobId, { + kind: 'done', + streamUrl, + }); + + return c.json({ ok: true }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Report generation failed'; + + await kv.set('report-status', job.jobId, { + kind: 'failed', + message, + }); + + return c.json({ ok: false }, 500); + } +}); + +export default app; +``` + + +The queue decouples the request from the worker. KV holds user-visible status so any route or client can check it without coupling to the worker. Durable streams hold output that is too large or too useful to fit in a response body. + + +## Poll from the Client + +Poll the status URL until the worker writes `done` or `failed`. Validate the response at the boundary so TypeScript knows which fields are present. + +```typescript +import { s } from '@agentuity/schema'; + +const reportStatusSchema = s.union( + s.object({ kind: s.literal('queued'), messageId: s.string() }), + s.object({ kind: s.literal('processing') }), + s.object({ kind: s.literal('done'), streamUrl: s.string() }), + s.object({ kind: s.literal('failed'), message: s.string() }) +); + +type ReportStatus = s.infer; + +async function waitForReport(statusUrl: string): Promise { + for (;;) { + const response = await fetch(statusUrl); + + if (!response.ok) { + throw new Error(`Status check failed: ${response.status}`); + } + + const body: unknown = await response.json(); + const status = reportStatusSchema.parse(body); // [!code highlight] + + if (status.kind === 'done' || status.kind === 'failed') { + return status; + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + } +} +``` + +## Pick the Right Service + +| Need | Service | +|------|---------| +| Async handoff between routes or workers | [Queues](/services/queues) | +| User-visible job status, fast reads | [Key-Value Storage](/services/storage/key-value) | +| Large generated output, shareable URL | [Durable Streams](/services/storage/durable-streams) | +| Recurring work on a timer | [Schedules](/services/schedules) | +| External event intake from Stripe, GitHub, etc. | [Webhooks](/services/webhooks) | + +## Common Gotchas + +| Symptom | Check | +|---------|-------| +| Route returns `202` but the worker never runs | The queue has no enabled HTTP destination; run `agentuity cloud queue destinations list report-jobs` to verify | +| Local worker never receives messages | The destination URL points at a public address, not `127.0.0.1`; use a tunnel for local testing | +| Duplicate button clicks enqueue duplicate jobs | Pass an `idempotencyKey` derived from a stable job ID | +| Status record disappears before the user checks it | Set a KV TTL that outlives the queue retry window | +| Stream URL returns empty content | The worker threw before calling `stream.close()`; always close in a `finally` block | + +## Next Steps + +- [Queues](/services/queues): full publish API with partitioning, metadata, DLQ, and WebSocket subscriptions +- [Key-Value Storage](/services/storage/key-value): TTL, namespaces, and key search +- [Durable Streams](/services/storage/durable-streams): compression, background writes, and shareable URLs +- [Schedules](/services/schedules): start recurring work without an incoming user request diff --git a/docs/src/web/content/build/chat-and-streaming.mdx b/docs/src/web/content/build/chat-and-streaming.mdx new file mode 100644 index 000000000..edcb0b383 --- /dev/null +++ b/docs/src/web/content/build/chat-and-streaming.mdx @@ -0,0 +1,207 @@ +--- +title: Chat and Streaming +description: Stream model output from framework routes and persist chat history with KV storage +--- + +Use streaming when the user should see tokens as they arrive rather than waiting for the full response. Persist chat state separately in KV, writing it only after the stream finishes. + +## Minimal Route + +The smallest-complete shape: stream a response, then store the assistant turn after the stream closes. + +```typescript +import { streamText } from 'ai'; +import { openai } from '@ai-sdk/openai'; + +const result = streamText({ // [!code highlight] + model: openai('gpt-5.5'), + messages, + async onFinish({ text }) { // [!code highlight] + // Write assistant turn only after the stream completes + // If the stream fails, history stays consistent + await kv.set('chat-history', conversationId, [ + ...messages, + { role: 'assistant', content: text }, + ]); + }, +}); + +return result.toTextStreamResponse(); // [!code highlight] +``` + +`onFinish` fires after the last token. Store the assistant message there, not before returning the response. + +## Complete Hono Route with KV History + +This route reads stored history, streams the next answer, then appends the assistant turn. + +```bash +bun add hono ai @ai-sdk/openai @agentuity/keyvalue@alpha @agentuity/schema@alpha @agentuity/telemetry@alpha +``` + +```typescript title="src/index.ts" +import { Hono } from 'hono'; +import { streamText } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { KeyValueClient } from '@agentuity/keyvalue'; +import { s } from '@agentuity/schema'; +import { logger } from '@agentuity/telemetry'; + +const inputSchema = s.object({ + conversationId: s.string(), + message: s.string(), +}); + +const messageSchema = s.object({ + role: s.enum(['user', 'assistant']), + content: s.string(), +}); + +const historySchema = s.array(messageSchema); + +type ChatMessage = s.infer; + +const kv = new KeyValueClient(); +const app = new Hono(); + +app.post('/api/chat', async (c) => { + const body: unknown = await c.req.json(); + const input = inputSchema.parse(body); + + const stored = await kv.get('chat-history', input.conversationId); + const history: ChatMessage[] = stored.exists + ? historySchema.parse(stored.data) + : []; + + const userMessage: ChatMessage = { role: 'user', content: input.message }; + const nextHistory = [...history, userMessage]; + + const result = streamText({ + model: openai('gpt-5.5'), + system: 'You are a concise product support assistant.', + messages: nextHistory, + async onFinish({ text }) { // [!code highlight] + const assistantMessage: ChatMessage = { role: 'assistant', content: text }; + + await kv.set( // [!code highlight] + 'chat-history', + input.conversationId, + [...nextHistory, assistantMessage], + { ttl: 60 * 60 * 24 * 30 }, // 30-day TTL + ); + + logger.info('conversation saved', { + conversationId: input.conversationId, + turns: nextHistory.length + 1, + }); + }, + }); + + return result.toTextStreamResponse(); // [!code highlight] +}); + +export default app; +``` + +`streamText()` returns a standard Web `Response`, so this works in Hono and any framework that accepts Web `Response` objects. The service client reads `AGENTUITY_SDK_KEY` from the project environment. + + +In `@agentuity/hono` routes use `c.var.logger`. In standalone framework apps like this one, import `logger` from `@agentuity/telemetry` instead. + + +## Read the Stream in the Browser + +For a custom UI, read the response body and append chunks as they arrive. This is the lowest-level browser shape. + +```typescript +async function sendMessage(conversationId: string, message: string): Promise { + const response = await fetch('/api/chat', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ conversationId, message }), + }); + + if (!response.ok) { + throw new Error(`Chat request failed with ${response.status}`); + } + + if (!response.body) { + throw new Error('Chat response did not include a body'); + } + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); // [!code highlight] + let assistantMessage = ''; + + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + assistantMessage += value; // [!code highlight] + } + + return assistantMessage; +} +``` + +If you use AI SDK UI helpers on the frontend, switch to `toUIMessageStreamResponse()` on the server instead of `toTextStreamResponse()`. That format includes tool call results, finish reason, and usage metadata in the stream. + +## Smoke Test with curl + +```bash +curl -N http://127.0.0.1:3500/api/chat \ + -H "content-type: application/json" \ + -d '{"conversationId":"demo","message":"Summarize Agentuity in one sentence"}' +``` + +`-N` disables output buffering so you see chunks as they arrive. + +## Choose the Right Stream Shape + +| Stream shape | Use it when | +|---|---| +| `toTextStreamResponse()` | client only needs plain text chunks | +| `toUIMessageStreamResponse()` | frontend uses AI SDK UI and needs tool calls, usage, finish reason | +| [Durable Streams](/services/storage/durable-streams) | output must survive a page refresh or be replayed later | +| Server-sent events | you need named events such as `status`, `chunk`, and `done` | + +Durable streams persist to storage and return a URL. Use them when the generated content is large, long-running, or must remain available after the HTTP connection closes. + +## Keep History Bounded + +Sending an unlimited transcript to the model on every request increases cost and latency. Keep a rolling summary and the most recent turns instead of the full history. + +```typescript +interface ChatMemory { + readonly summary: string; + readonly recent: readonly ChatMessage[]; +} + +// In onFinish, replace the flat history with a bounded memory object +const memory: ChatMemory = { + summary: existingSummary, // update periodically with a summarization call + recent: allMessages.slice(-8), // keep last 8 turns +}; + +await kv.set('chat-memory', input.conversationId, memory, { + ttl: 60 * 60 * 24 * 30, +}); +``` + + +Store a plain-text summary of older turns plus the last N messages. Pass both to the model as context: the summary as a system prompt addendum, the recent turns as the messages array. Update the summary every N turns with a separate `generateText` call. + + +## Common Gotchas + +| Symptom | Cause | Fix | +|---|---|---| +| History written even when stream fails | Saving to KV before returning the response | Move writes into `onFinish` | +| Context window errors after long conversations | Sending full history every request | Use bounded memory (summary + recent turns) | +| Client sees no chunks | Response buffered by middleware or proxy | Verify `Content-Type: text/plain; charset=utf-8` and no buffering layer | +| Tool calls missing from client | Using `toTextStreamResponse()` with AI SDK UI | Switch to `toUIMessageStreamResponse()` | +| KV TTL errors | TTL below minimum (60 s) or above maximum (365 days) | Use `ttl: null` or `ttl: 0` for never-expire; otherwise pass a value in `[60, 31_536_000]` seconds | + +## Next Steps + +- [Durable Streams](/services/storage/durable-streams): persist large or long-running output outside the HTTP response +- [Key-Value Storage](/services/storage/key-value): compact conversation state keyed by conversation ID +- [Build Agents](/build/agents): wrap model calls in typed, reusable app functions diff --git a/docs/src/web/content/build/index.mdx b/docs/src/web/content/build/index.mdx new file mode 100644 index 000000000..10624d18b --- /dev/null +++ b/docs/src/web/content/build/index.mdx @@ -0,0 +1,61 @@ +--- +title: Build +description: Build model calls, streaming routes, and background work in framework apps +--- + +import { Bot, Database, MessageSquareText, Rows3, Wrench } from 'lucide-react'; + +Add model calls, conversation state, tool use, and background work to a framework app that's already set up. + + + } + /> + } + /> + } + /> + } + /> + } + /> + + +## Pick a Starting Point + +- Build a reusable model-backed workflow: [Build Agents](/build/agents) +- Return tokens as they arrive: [Chat and Streaming](/build/chat-and-streaming) +- Let a model call app functions: [Tool Calling](/build/tool-calling) +- Store memory outside the response: [State and Memory](/build/state-and-memory) +- Move slow work out of the request: [Background Work](/build/background-work) + +The examples use direct service clients because that path works across all supported frameworks and scripts. Hono apps can also install `@agentuity/hono` and read the same clients from `c.var.*`. + + +These pages treat "agent" as an application design pattern, not as `createAgent()`. If you're on the legacy runtime, use the [legacy docs](https://v2.agentuity.dev) instead. + + +## Related Docs + +- [Frameworks](/frameworks): start from Next.js, Hono, TanStack Start, or another supported framework +- [Services](/services): choose the Agentuity client for storage, queues, tasks, schedules, sandbox, and observability +- [Deploy and Operate](/deploy-operate): run the same framework project with `agentuity dev` and `agentuity deploy` diff --git a/docs/src/web/content/build/meta.json b/docs/src/web/content/build/meta.json new file mode 100644 index 000000000..39b70f69b --- /dev/null +++ b/docs/src/web/content/build/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Build", + "pages": ["agents", "chat-and-streaming", "tool-calling", "state-and-memory", "background-work"] +} diff --git a/docs/src/web/content/build/state-and-memory.mdx b/docs/src/web/content/build/state-and-memory.mdx new file mode 100644 index 000000000..a4c33b4cd --- /dev/null +++ b/docs/src/web/content/build/state-and-memory.mdx @@ -0,0 +1,216 @@ +--- +title: State and Memory +description: Store app state explicitly with KV, databases, cookies, and service clients +--- + +State belongs to your app. Use cookies, auth sessions, or request headers to identify the current browser or user, then store durable state in KV or a database with keys you control. Nothing is stored automatically. + +```bash +bun add hono @agentuity/keyvalue@alpha @agentuity/schema@alpha @agentuity/telemetry@alpha +``` + +## Choose the State Boundary + +Pick the right store before writing any code. The wrong boundary is the most common source of memory bugs. + +| What you need | Where it lives | +|---------------|---------------| +| browser session identity | signed cookie or your auth session | +| compact user or conversation memory | [Key-Value Storage](/services/storage/key-value) | +| relational user or entity data | [Database](/services/database) | +| generated files, transcripts, or exports | [Durable Streams](/services/storage/durable-streams) | +| async job status | KV status record plus [Queues](/services/queues) | + +The examples on this page use KV because that data is compact and fetched by exact key. + +## Store Session Memory + +This route creates a signed browser session cookie when one does not exist, then reads and writes typed memory under that session ID. + +```typescript title="src/index.ts" +import { KeyValueClient } from '@agentuity/keyvalue'; +import { s } from '@agentuity/schema'; +import { Hono } from 'hono'; +import type { Context } from 'hono'; +import { getCookie, setCookie } from 'hono/cookie'; + +const memorySchema = s.object({ + displayName: s.string(), + lastIntent: s.string(), +}); + +type UserMemory = s.infer; + +const kv = new KeyValueClient(); +const app = new Hono(); + +// Returns an existing session ID from the cookie, or creates and sets a new one +function getOrCreateSessionId(c: Context): string { + const existing = getCookie(c, 'app_session'); + + if (existing) { + return existing; + } + + const sessionId = crypto.randomUUID(); + + setCookie(c, 'app_session', sessionId, { + httpOnly: true, // not readable from JS + sameSite: 'Lax', // safe for most single-domain apps + secure: new URL(c.req.url).protocol === 'https:', + maxAge: 60 * 60 * 24 * 30, // 30 days + }); + + return sessionId; +} + +app.post('/api/memory', async (c) => { + const sessionId = getOrCreateSessionId(c); // [!code highlight] + const body: unknown = await c.req.json(); + const memory = memorySchema.parse(body); + + await kv.set('app-memory', sessionId, memory, { // [!code highlight] + ttl: 60 * 60 * 24 * 30, // match cookie lifetime + }); + + return c.json({ ok: true }); +}); + +app.get('/api/memory', async (c) => { + const sessionId = getOrCreateSessionId(c); + const result = await kv.get('app-memory', sessionId); // [!code highlight] + + return c.json({ + memory: result.exists ? result.data : null, + }); +}); + +export default app; +``` + +`KeyValueClient` auto-reads `AGENTUITY_SDK_KEY` from the environment. Run `agentuity dev` locally and the key is injected automatically. + + +If you have already added `@agentuity/hono`, `kv` is available on `c.var` after the `agentuity()` middleware. Replace `new KeyValueClient()` with `c.var.kv` inside each handler. See [Hono](/frameworks/hono) for setup details. + + + +Cookie helpers are framework-specific. Nuxt and h3 expose `getCookie`/`setCookie` from `h3`. Next.js App Router exposes `cookies()` from `next/headers`. SvelteKit exposes `cookies` on the request event. The KV calls are identical across all of them. + + +## Choose the Key + +Use the most stable identifier available. Authenticated apps should key memory by user ID. Anonymous apps can fall back to a signed cookie, device token, or conversation ID. + +```typescript +// Prefer authenticated user ID; fall back to anonymous session ID +function memoryKey(userId: string | null, sessionId: string): string { + return userId ? `user:${userId}` : `session:${sessionId}`; +} + +const key = memoryKey(userId, sessionId); // [!code highlight] + +await kv.set('app-memory', key, memory, { // [!code highlight] + ttl: 60 * 60 * 24 * 30, +}); +``` + +Do not key durable user memory by request ID. Request IDs are useful for logs and tracing but do not identify the next request from the same user. + +## Validate on Read + +KV stores raw JSON. Treat every read as a boundary: the value may have been written by older code, a migration script, or a manual test. + +```typescript +import { logger } from '@agentuity/telemetry'; + +const result = await kv.get('app-memory', sessionId); // [!code highlight] + +if (!result.exists) { + return null; +} + +// safeParse returns { success: true, data } | { success: false, error: ValidationError } +const parsed = memorySchema.safeParse(result.data); // [!code highlight] + +if (!parsed.success) { + // Log and discard stale or malformed records rather than crashing + logger.warn('Dropping unrecognised memory record', { + sessionId, + issues: parsed.error.issues, + }); + return null; +} + +return parsed.data; +``` + +Inside `@agentuity/hono` route handlers, replace `logger` with `c.var.logger` so logs are scoped to the request span. + +`kv.get()` with a concrete type is fine when your app owns every write path. `safeParse` is the safer choice when the data shape can drift between deploys. + + +`result.data` is only accessible when `result.exists` is `true`. TypeScript enforces this: accessing `result.data` without the check is a type error. + + +## Keep Memory Small + +Store summaries, preferences, IDs, and pointers. Put large transcripts in a database or durable stream, then store only the reference in KV. + +```typescript +interface ConversationPointer { + readonly summary: string; + readonly streamId: string; // points to the full transcript + readonly updatedAt: string; +} + +await kv.set('conversation-memory', sessionId, { + summary: 'User prefers concise answers about billing issues', + streamId: 'stream_01j...', // [!code highlight] + updatedAt: new Date().toISOString(), +}); +``` + +A KV value holding a summary and a reference ID stays small and fast to read. The full transcript stays in the stream where it belongs. + +## Retention + +Match TTL to the way state is used. Keys with no TTL override inherit the namespace default (7 days for auto-created namespaces). Pass `ttl: null` or `ttl: 0` to store a key that never expires. + +| State type | Suggested TTL | +|------------|--------------| +| anonymous browser session | 7 to 30 days | +| signed-in user preference | `null` (no expiry) or database-backed | +| API cache entry | minutes or hours | +| async job status | long enough for the UI to poll and for support to review failures | +| generated output pointer | no longer than the stream or file it points to | + +```typescript +// Never expires +await kv.set('user-prefs', userId, prefs, { ttl: null }); // [!code highlight] + +// Expires after 30 days +await kv.set('app-memory', sessionId, memory, { ttl: 60 * 60 * 24 * 30 }); // [!code highlight] + +// Inherits namespace default (7 days if the namespace was auto-created) +await kv.set('cache', cacheKey, value); +``` + +See [Key-Value Storage](/services/storage/key-value) for namespace-level TTL defaults and key inspection. + +## Common Gotchas + +| Symptom | Check | +|---------|-------| +| memory never appears on the next request | the cookie domain, `sameSite`, and `secure` flags match the deployed host | +| one user sees another user's state | the key includes the authenticated user ID or anonymous session ID | +| old values fail `safeParse` | read with `unknown`, run `safeParse`, then migrate or drop stale records | +| chat history grows without bound | store a summary plus recent turns, not the full transcript | +| keys disappear unexpectedly | the per-key or namespace TTL is shorter than expected; check `result.expiresAt` on a read | + +## Next Steps + +- [Build Agents](/build/agents): read memory before model calls +- [Chat and Streaming](/build/chat-and-streaming): persist chat history after streamed responses +- [Key-Value Storage](/services/storage/key-value): configure TTL, inspect namespaces, and understand sliding expiration +- [Migrating from Runtime Apps](/migration/runtime-to-frameworks): map older runtime state surfaces to framework app state diff --git a/docs/src/web/content/build/tool-calling.mdx b/docs/src/web/content/build/tool-calling.mdx new file mode 100644 index 000000000..025055f76 --- /dev/null +++ b/docs/src/web/content/build/tool-calling.mdx @@ -0,0 +1,366 @@ +--- +title: Tool Calling +description: Let models call bounded app functions from framework routes +--- + +Tool calling lets a model request structured function results mid-generation. Your app runs the function, returns the result, and the model continues with that data. Use tools when the answer depends on live app state: a database record, an API response, or a typed action result. + +For chat-style tool loops that span requests, see [State and Memory](/build/state-and-memory). + +```bash +bun add ai @ai-sdk/openai @agentuity/keyvalue@alpha zod +``` + +## Choose the Right Loop + +| Use | Good fit | +|-----|----------| +| [AI SDK 6](#ai-sdk-6) | one typed tool loop across providers, minimal boilerplate | +| [OpenAI Responses API](#openai-responses-api) | apps already on Responses with `previous_response_id` turn chaining | +| [Anthropic Messages API](#anthropic-messages-api) | apps that need `tool_use` block fidelity or `strict: true` on every tool | + +## What Belongs in a Tool + +| Good fit | Avoid | +|----------|-------| +| Read one record from KV, Postgres, or an external API | Broad database scans with no query limit | +| Write a small status update after user approval | Irreversible side effects without a decision gate | +| Enqueue a job or create a task | Long-running work inside the request lifecycle | +| Return a compact, typed result | Returning large files, full transcripts, or raw blobs | + +## AI SDK 6 + +AI SDK 6 handles the tool loop internally. `generateText` calls your `execute` function, injects the result into the next turn, and continues until the model stops or `stopWhen` fires. All tools are validated with `inputSchema` before `execute` runs. + +```typescript title="app/api/orders/answer/route.ts" +import { KeyValueClient } from '@agentuity/keyvalue'; +import { openai } from '@ai-sdk/openai'; +import { generateText, stepCountIs, tool } from 'ai'; +import { z } from 'zod'; + +const requestSchema = z.object({ + orderId: z.string(), + question: z.string(), +}); + +interface OrderSummary { + readonly orderId: string; + readonly status: string; + readonly eta: string; +} + +const kv = new KeyValueClient(); + +async function answerOrderQuestion(orderId: string, question: string): Promise { + const { text } = await generateText({ + model: openai('gpt-5.5'), + system: 'Answer using only the order facts returned by tools.', + prompt: question, + tools: { + getOrder: tool({ // [!code highlight] + description: 'Read the current order summary for a given order ID.', + inputSchema: z.object({ + id: z.string().describe('Order ID to look up'), + }), + execute: async ({ id }): Promise => { // [!code highlight] + const result = await kv.get('orders', id); + // DataResult is discriminated on `exists` + return result.exists + ? result.data + : { orderId: id, status: 'unknown', eta: 'unknown' }; + }, + }), + }, + // Prevent infinite loops if the model keeps requesting tool results + stopWhen: stepCountIs(4), // [!code highlight] + }); + + // Keep a short audit trail for support debugging + await kv.set('order-support', orderId, { question, answer: text }, { ttl: 3600 }); + + return text; +} + +export async function POST(request: Request): Promise { + const body: unknown = await request.json(); + const input = requestSchema.parse(body); + const answer = await answerOrderQuestion(input.orderId, input.question); + return Response.json({ answer }); +} +``` + + + Each `generateText` iteration (whether or not it makes a tool call) is one step. `stepCountIs(4)` stops after four turns regardless of how many individual tool calls occurred in those turns. + + +### Require Approval for Risky Tools + +Use `needsApproval` when the model can propose an action but your app should gate execution on a user decision. When `needsApproval` returns `true`, `execute` is skipped and the pending tool call is surfaced in the result. + +```typescript +import { tool } from 'ai'; +import { z } from 'zod'; + +const refundOrder = tool({ + description: 'Issue a refund for an order. Requires user approval for amounts above $100.', + inputSchema: z.object({ + orderId: z.string(), + amount: z.number().describe('Refund amount in USD'), + }), + // Skip execution and surface the call for approval when amount is large + needsApproval: async ({ amount }) => amount > 100, // [!code highlight] + execute: async ({ orderId, amount }) => { + // Only runs after your app approves the pending tool call + return { orderId, amount, status: 'refunded' }; + }, +}); +``` + +Pending tool calls appear in the model result's content. Store the pending action in your app state, collect the user's decision, then send the approval response back through your chat or workflow loop. + +## OpenAI Responses API + +Use this pattern when your app is already on the Responses API and you want to chain turns using `previous_response_id`. The Responses API accumulates conversation state server-side so you only need to send new input items in follow-up requests. + +```bash +bun add openai @agentuity/keyvalue@alpha zod +``` + +The Responses API returns `function_call` items in `response.output`. Run your tool, then send a `function_call_output` item with the matching `call_id` in a follow-up request. Pass `previous_response_id` to reuse the model's prior turn state. + +```typescript title="app/api/orders/openai-answer/route.ts" +import { KeyValueClient } from '@agentuity/keyvalue'; +import OpenAI from 'openai'; +import type { + FunctionTool, + ResponseFunctionToolCall, + ResponseOutputItem, +} from 'openai/resources/responses/responses'; +import { z } from 'zod'; + +const orderToolInputSchema = z.object({ + id: z.string(), +}); + +interface OrderSummary { + readonly orderId: string; + readonly status: string; + readonly eta: string; +} + +const kv = new KeyValueClient(); +const openai = new OpenAI(); + +// strict: true requires additionalProperties: false and all properties in required[] +const getOrderTool: FunctionTool = { + type: 'function', + name: 'get_order', + description: 'Read the current order summary for a support workflow.', + strict: true, + parameters: { + type: 'object', + properties: { + id: { type: 'string', description: 'Order ID' }, + }, + required: ['id'], + additionalProperties: false, + }, +}; + +async function readOrderSummary(id: string): Promise { + const result = await kv.get('orders', id); + return result.exists + ? result.data + : { orderId: id, status: 'unknown', eta: 'unknown' }; +} + +function isOrderToolCall(item: ResponseOutputItem): item is ResponseFunctionToolCall { + return item.type === 'function_call' && item.name === 'get_order'; +} + +export async function answerWithOpenAI(question: string): Promise { + const response = await openai.responses.create({ + model: 'gpt-5.5', + input: question, + tools: [getOrderTool], + }); + + const toolCall = response.output.find(isOrderToolCall); // [!code highlight] + if (!toolCall) { + return response.output_text; + } + + const parsedInput = orderToolInputSchema.parse(JSON.parse(toolCall.arguments)); + const order = await readOrderSummary(parsedInput.id); + + // previous_response_id tells the API to reuse the conversation state from + // the first turn so we only need to send the new function_call_output item + const finalResponse = await openai.responses.create({ + model: 'gpt-5.5', + previous_response_id: response.id, // reuse model state from the tool-call turn // [!code highlight] + input: [ + { + type: 'function_call_output', // [!code highlight] + call_id: toolCall.call_id, + output: JSON.stringify(order), + }, + ], + tools: [getOrderTool], + }); + + return finalResponse.output_text; +} +``` + + + This example handles one tool call per turn. If the model emits multiple `function_call` items, iterate `response.output`, collect all `function_call_output` items, and send them together in the follow-up request. Use the same `previous_response_id` for all results from a single turn. + + +## Anthropic Messages API + +Use this pattern when your app uses the Anthropic SDK directly and needs `tool_use` block fidelity, such as when you log tool calls for auditing or need `strict: true` schema enforcement on every invocation. + +```bash +bun add @anthropic-ai/sdk @agentuity/keyvalue@alpha zod +``` + +The Messages API returns `tool_use` blocks when the model wants to call a tool (`stop_reason: 'tool_use'`). Continue the conversation by appending the prior assistant message and a new user message whose content is the `tool_result` block. + +```typescript title="app/api/orders/anthropic-answer/route.ts" +import { KeyValueClient } from '@agentuity/keyvalue'; +import Anthropic from '@anthropic-ai/sdk'; +import type { + ContentBlock, + TextBlockParam, + Tool, + ToolUseBlock, + ToolUseBlockParam, +} from '@anthropic-ai/sdk/resources/messages'; +import { z } from 'zod'; + +const orderToolInputSchema = z.object({ + id: z.string(), +}); + +interface OrderSummary { + readonly orderId: string; + readonly status: string; + readonly eta: string; +} + +const kv = new KeyValueClient(); +const anthropic = new Anthropic(); + +// input_schema (not parameters) is the Anthropic field name for the JSON Schema +// strict: true enforces schema conformance on Anthropic Tool calls +const getOrderTool: Tool = { + name: 'get_order', + description: 'Read the current order summary for a support workflow.', + input_schema: { // [!code highlight] + type: 'object', + properties: { + id: { type: 'string', description: 'Order ID' }, + }, + required: ['id'], + }, + strict: true, +}; + +async function readOrderSummary(id: string): Promise { + const result = await kv.get('orders', id); + return result.exists + ? result.data + : { orderId: id, status: 'unknown', eta: 'unknown' }; +} + +function isOrderToolUse(block: ContentBlock): block is ToolUseBlock { + return block.type === 'tool_use' && block.name === 'get_order'; +} + +function textFromContent(blocks: readonly ContentBlock[]): string { + return blocks + .filter((b): b is Extract => b.type === 'text') + .map((b) => b.text) + .join('\n'); +} + +// The follow-up message must echo back the full assistant content (including +// tool_use blocks) so Anthropic can match tool_result to the correct call +function toAssistantContent( + blocks: readonly ContentBlock[] +): Array { + const content: Array = []; + for (const block of blocks) { + if (block.type === 'text') content.push({ type: 'text', text: block.text }); + if (block.type === 'tool_use') { + content.push({ type: 'tool_use', id: block.id, name: block.name, input: block.input }); + } + } + return content; +} + +export async function answerWithAnthropic(question: string): Promise { + const message = await anthropic.messages.create({ + model: 'claude-sonnet-4-6', + max_tokens: 1024, + system: 'Answer using only the order facts returned by tools.', + messages: [{ role: 'user', content: question }], + tools: [getOrderTool], + }); + + const toolUse = message.content.find(isOrderToolUse); + if (!toolUse) { + return textFromContent(message.content); + } + + const parsedInput = orderToolInputSchema.parse(toolUse.input); + const order = await readOrderSummary(parsedInput.id); + + const finalMessage = await anthropic.messages.create({ + model: 'claude-sonnet-4-6', + max_tokens: 1024, + system: 'Answer using only the order facts returned by tools.', + messages: [ + { role: 'user', content: question }, + // Echo back the full assistant content so Anthropic can match tool_result IDs + { role: 'assistant', content: toAssistantContent(message.content) }, // [!code highlight] + { + role: 'user', + // tool_result must be the first (or only) content block in this user turn + content: [ + { + type: 'tool_result', // [!code highlight] + tool_use_id: toolUse.id, + content: JSON.stringify(order), + }, + ], + }, + ], + tools: [getOrderTool], + }); + + return textFromContent(finalMessage.content); +} +``` + + + This example handles one tool call. For multi-tool or multi-turn loops, keep calling `anthropic.messages.create` and appending the assistant and `tool_result` messages until `message.stop_reason !== 'tool_use'`. + + +## Common Gotchas + +| Symptom | Check | +|---------|-------| +| Model keeps calling the same tool | Cap the loop with `stopWhen: stepCountIs(N)` or an equivalent sentinel in your provider loop | +| Tool input is missing required fields | Add `.describe()` hints on each schema field and validate before executing any side effect | +| OpenAI loop loses context after a tool call | Pass `previous_response_id` from the prior response, or accumulate all output items into the next `input` array | +| Anthropic loop returns another `tool_use` block | Keep looping until `stop_reason !== 'tool_use'`; never drop the assistant message between turns | +| Tool output is too large | Store the full result in KV or Streams and return a compact pointer (ID or summary) | +| `needsApproval` skipping execution unexpectedly | `needsApproval` returning `true` means approval is required, not granted; check your approval response flow | + +## Next Steps + +- [State and Memory](/build/state-and-memory): persist tool results and pending approvals across requests +- [Background Work](/build/background-work): enqueue tool actions that should outlive the HTTP request +- [Key-Value Storage](/services/storage/key-value): keep compact state by namespace and key +- [AI Gateway](/services/ai-gateway): route LLM requests through Agentuity for observability and cost tracking diff --git a/docs/src/web/content/community/inbound-email-agent.mdx b/docs/src/web/content/community/inbound-email-agent.mdx deleted file mode 100644 index 4b4cc6dcf..000000000 --- a/docs/src/web/content/community/inbound-email-agent.mdx +++ /dev/null @@ -1,351 +0,0 @@ ---- -title: Inbound Email Agent -description: Create an AI email auto-responder with Agentuity + Inbound webhooks. ---- - -Build an AI email auto-responder: incoming messages trigger an Agentuity agent that classifies intent, drafts a reply, and sends it back through [Inbound](https://inbound.new). - -## What You'll Build - -- An email agent that classifies intent and drafts a response -- A webhook route at `/api/email/inbound` that receives email events from Inbound -- An automatic reply flow using `inbound.reply(...)` -- A deployed endpoint you can connect to an Inbound domain or catch-all address - -## Prerequisites - -- [Bun](https://bun.sh/) v1.0+ -- [Agentuity CLI](/reference/cli/getting-started) -- Agentuity account -- [Inbound](https://inbound.new) account -- A domain you control for inbound email - -Add environment variables: - -```dotenv title=".env" -AGENTUITY_SDK_KEY=your_agentuity_sdk_key -INBOUND_API_KEY=your_inbound_api_key -``` - - -`AGENTUITY_SDK_KEY` is generated when you run `agentuity project create` or `agentuity project import`. Never commit API keys to version control. - - -## Project Structure - -```text -src/ -├── agent/ -│ ├── index.ts -│ └── email/ -│ └── index.ts -├── api/ -│ └── index.ts -app.ts -.env -``` - - - - -## Create the Project - -```bash -agentuity project create --name inbound-email-agent -cd inbound-email-agent -bun add openai @inboundemail/sdk -``` - -The CLI scaffolds a new project and registers it with Agentuity. `bun add` installs the OpenAI and Inbound SDKs. - - - -## Create the Email Agent - -Create your inbound-email agent in `src/agent/email/index.ts`: - -```typescript title="src/agent/email/index.ts" -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; -import OpenAI from 'openai'; - -export const AgentInput = s.object({ - from: s.string().describe('Sender email address'), - subject: s.string().describe('Email subject line'), - body: s.string().describe('Email body content'), - to: s.string().optional().describe('Recipient email address'), - replyTo: s.string().optional().describe('Reply-to address if different from sender'), -}); - -const ResponseDraft = s.object({ - response: s.string().describe('AI-generated email response'), - subject: s.string().describe('Subject line for the response'), - summary: s.string().describe('Brief summary of the original email'), - sentiment: s.string().describe('Detected sentiment of the incoming email'), -}); - -export const AgentOutput = s.object({ - response: s.string().describe('AI-generated email response'), - subject: s.string().describe('Subject line for the response'), - summary: s.string().describe('Brief summary of the original email'), - sentiment: s.string().describe('Detected sentiment of the incoming email'), - threadId: s.string().describe('Thread ID for conversation continuity'), -}); - -const agent = createAgent('email', { - description: 'Processes inbound emails and generates replies', - schema: { - input: AgentInput, - output: AgentOutput, - }, - setup: async () => { - return { - client: new OpenAI(), - }; - }, - handler: async (ctx, { from, subject, body, to }) => { - const completion = await ctx.config.client.chat.completions.create({ - model: 'gpt-5.4-nano', - response_format: { - type: 'json_schema', - json_schema: { - name: 'email_response', - schema: s.toJSONSchema(ResponseDraft, { strict: true }), - strict: true, - }, - }, - messages: [ - { - role: 'system', - content: 'You are a helpful email assistant. Draft concise, professional replies.', - }, - { - role: 'user', - content: `From: ${from}\nSubject: ${subject}\n${to ? `To: ${to}` : ''}\n\n${body}`, - }, - ], - }); - - const content = completion.choices[0]?.message?.content; - if (!content) { - throw new Error('OpenAI did not return a response'); - } - - const draft = ResponseDraft.parse(JSON.parse(content)); - - ctx.logger.info('Drafted email response', { - sentiment: draft.sentiment, - summary: draft.summary, - }); - - return { - ...draft, - threadId: ctx.thread.id, - }; - }, -}); - -export default agent; -``` - - - -## Register the Agent - -Add the agent to `src/agent/index.ts` so it is included in the build: - -```typescript title="src/agent/index.ts" -import email from './email'; - -export default [email]; -``` - - - -## Add the Inbound Webhook Route - -Create the route handler in `src/api/index.ts`: - -```typescript title="src/api/index.ts" -import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; -import { Inbound, isInboundWebhook, verifyWebhookFromHeaders } from '@inboundemail/sdk'; -import email from '@agent/email'; - -const inboundApiKey = process.env.INBOUND_API_KEY; -if (!inboundApiKey) { - throw new Error('INBOUND_API_KEY is required'); -} - -const inbound = new Inbound(inboundApiKey); -const api = new Hono(); - -api.post('/email/inbound', async (c) => { - const verified = await verifyWebhookFromHeaders(c.req.raw.headers, inbound); - if (!verified) { - c.var.logger.warn('Invalid Inbound webhook signature'); - return c.json({ error: 'Invalid webhook signature' }, 401); - } - - const payload: unknown = await c.req.json(); - - if (!isInboundWebhook(payload)) { - c.var.logger.error('Invalid Inbound webhook payload'); - return c.json({ error: 'Invalid webhook payload' }, 400); - } - - const emailData = payload.email; - - const agentInput = { - from: emailData.from?.addresses?.[0]?.address ?? '', - subject: emailData.subject ?? '', - body: emailData.cleanedContent?.text ?? emailData.parsedData?.textBody ?? '', - to: emailData.to?.addresses?.[0]?.address ?? '', - }; - - c.var.logger.info('Processing inbound email', { - from: agentInput.from, - subject: agentInput.subject, - }); - const result = await email.run(agentInput); - - try { - const { data: reply, error } = await inbound.reply( - emailData, - { - from: agentInput.to, - subject: result.subject, - text: result.response, - }, - { - idempotencyKey: `email-reply-${emailData.id}`, - } - ); - - if (error || !reply) { - const message = error ?? 'Inbound did not return a reply'; - c.var.logger.error('Failed to send reply', { error: message }); - return c.json({ ...result, replySent: false, error: message }, 500); - } - - c.var.logger.info('Reply sent', { replyId: reply.id }); - return c.json({ ...result, replySent: true, replyId: reply.id }); - } catch (err) { - c.var.logger.error('Failed to send reply', { err: String(err) }); - return c.json({ ...result, replySent: false, error: String(err) }, 500); - } -}); - -export default api; -``` - - -See [Webhook Structure](https://docs.inbound.new/webhook) for the full payload shape and [Reply to Email](https://docs.inbound.new/api-reference/emails/reply-to-email) for reply options. - - - - -## Test Locally with curl - -Start the app: - -```bash -agentuity dev -``` - -Send a sample inbound payload: - -```bash -curl -X POST http://localhost:3500/api/email/inbound \ - -H "Content-Type: application/json" \ - -H "X-Endpoint-ID: endp_test123" \ - -H "X-Webhook-Verification-Token: your_endpoint_verification_token" \ - -d '{ - "event": "email.received", - "timestamp": "2026-04-22T12:00:00Z", - "email": { - "id": "test_123", - "from": { "addresses": [{ "address": "test@example.com" }] }, - "to": { "addresses": [{ "address": "hello@yourdomain.com" }] }, - "subject": "Test email", - "cleanedContent": { "text": "Hello, this is a test!" } - }, - "endpoint": { - "id": "endp_test123", - "name": "Local test", - "type": "webhook" - } - }' -``` - -Use the endpoint ID and verification token from Inbound. Requests without valid Inbound webhook headers return `401`. - - - -## Deploy and Connect Inbound - -Deploy your app: - -```bash -agentuity deploy -``` - -In [Inbound](https://docs.inbound.new/api-reference/endpoints/create-endpoint), create an endpoint with: - -- URL: `https://your-app.agentuity.run/api/email/inbound` -- Event: `email.received` - -Then either: - -- Attach a specific address (example: `hello@yourdomain.com`), or -- Configure a catch-all endpoint for your domain. - -To attach an address, see [Create Email Address](https://docs.inbound.new/api-reference/email-addresses/create-email-address). - - - -## Send a Real Email and Verify - -Send an email to the configured address and verify: - -- Inbound shows a successful webhook delivery. -- Your Agentuity app logs show route execution. -- The original sender receives the AI-generated reply. - -For debugging deployed behavior, use [Debugging Deployments](/reference/cli/debugging). - - - - -## Extend the Agent - -- Use `ctx.thread.state` to keep multi-email conversation context. -- Route messages by `to` address to different agents. -- Process attachment metadata and hand off to specialized workflows. -- Tune prompt/model per mailbox intent (support, sales, billing, etc.). - -## Troubleshooting - -### Webhook returns 400 - -- Confirm endpoint path is exactly `/api/email/inbound`. -- Confirm request body includes `email` and nested address/content fields. - -### Replies are not sending - -- Verify `INBOUND_API_KEY` is present and valid. -- Ensure the `from` address is valid for your Inbound domain setup. -- Check Inbound delivery logs for provider errors. - -### Domain is not receiving mail - -- Re-check MX/TXT records. -- Wait for DNS propagation before retesting. -- Verify the domain is fully verified in Inbound. - -## Next Steps - -- [Deploying to the Cloud](/reference/cli/deployment): Push your agent to Agentuity's hosted runtime -- [Debugging Deployments](/reference/cli/debugging): Inspect logs and traces for deployed agents -- [State Management](/agents/state-management): Persist conversation history across email threads -- [Webhook Handler Pattern](/cookbook/patterns/webhook-handler): Patterns for receiving external webhooks diff --git a/docs/src/web/content/community/index.mdx b/docs/src/web/content/community/index.mdx index ee033876c..fe89612b0 100644 --- a/docs/src/web/content/community/index.mdx +++ b/docs/src/web/content/community/index.mdx @@ -6,17 +6,6 @@ description: Real-world integrations and tutorials built with Agentuity ## From the Community - - Inbound - - } - /> - } - /> } + title="Build a RAG Agent" + description="Index documents into vector storage, retrieve the closest matches, and answer with citations" + icon={} /> ## Patterns +Reusable shapes for common app problems. Each pattern is one focused job. + + + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + ### Coder +Manage [Coder](/services/coder) sessions, skills, and workspaces from the SDK. + -### More Patterns - - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - ## Integrations +External libraries that run on or alongside Agentuity. Framework setup lives under [Frameworks](/frameworks). + Mastra} - /> - LangChain} + description="Run a Mastra Agent inside a framework route with key-value memory" + icon={} /> OpenAI} + description="Drive @openai/agents tool calls and handoffs from a Hono route" + icon={} /> Anthropic} - /> - Chat SDK} - /> - Next.js} - /> - TanStack} + description="Run multi-turn Claude Agent sessions and store the session ID per conversation" + icon={} /> Turborepo} + href="/cookbook/integrations/langchain" + title="LangChain" + description="Wrap a LangChain createAgent loop in a framework route with middleware" + icon={} /> + + +Framework integration pages live under [Frameworks](/frameworks). The cookbook is for external libraries, not framework setup. + diff --git a/docs/src/web/content/cookbook/integrations/chat-sdk.mdx b/docs/src/web/content/cookbook/integrations/chat-sdk.mdx deleted file mode 100644 index 4c1f5bf4d..000000000 --- a/docs/src/web/content/cookbook/integrations/chat-sdk.mdx +++ /dev/null @@ -1,246 +0,0 @@ ---- -title: Using Chat SDK with Agentuity -short_title: Chat SDK -description: Build multi-platform chatbots for Slack and Discord with Chat SDK and Agentuity agents ---- - -[Chat SDK](https://chat-sdk.dev) handles platform connections: authenticating with Slack and Discord, receiving webhooks, normalizing messages, and posting responses. Agentuity provides the agent runtime, [KV storage](/services/storage/key-value) for conversation history, and the [AI Gateway](/agents/ai-gateway) for LLM calls. One `handleMessage` function serves both platforms. - -## The Integration Pattern - -Chat SDK manages platform adapters and webhook routing. The Agentuity agent handles the AI logic: loading history, generating responses, and persisting state. The two connect through a simple `chatAgent.run()` call. - -Set up Chat SDK with platform adapters: - -```tsx title="src/lib/bot.tsx" -/** @jsxImportSource chat */ -import { Chat } from 'chat'; -import type { Adapter, Message, Thread } from 'chat'; -import { createSlackAdapter } from '@chat-adapter/slack'; -import { createDiscordAdapter } from '@chat-adapter/discord'; -import { createMemoryState } from '@chat-adapter/state-memory'; -import chatAgent from '@agent/chat'; - -const hasSlackConfig = process.env.SLACK_BOT_TOKEN && process.env.SLACK_SIGNING_SECRET; -const hasDiscordConfig = - process.env.DISCORD_BOT_TOKEN && - process.env.DISCORD_PUBLIC_KEY && - process.env.DISCORD_APPLICATION_ID; - -const adapters = { - ...(hasSlackConfig - ? { slack: createSlackAdapter() } - : {}), - ...(hasDiscordConfig - ? { discord: createDiscordAdapter() } - : {}), -} satisfies Record; - -export const bot = new Chat({ - userName: 'agentuity-bot', - adapters, - state: createMemoryState(), -}); -``` - -Adapters are conditionally enabled based on environment variables. Set `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` for Slack, or `DISCORD_BOT_TOKEN`, `DISCORD_PUBLIC_KEY`, and `DISCORD_APPLICATION_ID` for Discord. You only need to configure the platforms you want to use. - - -`createMemoryState()` is fine for local development. For bots that need thread subscriptions to survive restarts or multiple instances, use Chat SDK's Redis, ioredis, or Postgres state adapters. - - -## Handling Messages - -A single handler serves both platforms. Chat SDK normalizes the incoming message format, so `message.text` and `thread.id` work regardless of whether the message came from Slack or Discord: - -```tsx title="src/lib/bot.tsx" -async function handleMessage(thread: Thread, message: Message) { - if (!message.text.trim()) return; - await thread.startTyping(); - - try { - const result = await chatAgent.run({ text: message.text, threadId: thread.id }); // [!code highlight] - await thread.post(result.response); - } catch (err) { - console.error('[chat-bot] agent invocation failed', err); - await thread.post('Sorry, I ran into an error. Please try again.'); - } -} - -// Subscribe to new @mentions and follow-up messages -bot.onNewMention(async (thread, message) => { // [!code highlight] - await thread.subscribe(); - await handleMessage(thread, message); -}); - -bot.onSubscribedMessage(async (thread, message) => { - await handleMessage(thread, message); -}); -``` - -`chatAgent.run()` calls the Agentuity agent directly. Chat SDK handles threading, typing indicators, and response posting. The bot subscribes to a thread on the first @mention, then receives all follow-up messages automatically. - -## Conversation Memory with KV - -The agent stores conversation history in [Agentuity KV](/services/storage/key-value) rather than `ctx.thread.state` or Chat SDK's built-in state. This gives longer retention (24 hours vs. 1 hour), visibility in the Agentuity dashboard, and works with webhook-based bots that don't have browser cookies: - -```typescript title="src/agent/chat/agent.ts" -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; -import { generateText } from 'ai'; -import { anthropic } from '@ai-sdk/anthropic'; - -const ChatMessageSchema = s.object({ - role: s.union(s.literal('user'), s.literal('assistant')), - content: s.string(), -}); - -const StoredHistorySchema = s.object({ - messages: s.array(ChatMessageSchema), -}); - -type ChatMessage = s.infer; - -export default createAgent('chat', { - schema: { - input: s.object({ - text: s.string(), - threadId: s.string(), - }), - output: s.object({ response: s.string() }), - }, - handler: async (ctx, { text, threadId }) => { - ctx.logger.info('Chat request', { messageLength: text.length, threadId }); - - // KV keyed by Chat SDK thread ID: browsable in dashboard, 24h retention - const stored = await ctx.kv.get('chat-sdk-conversations', threadId); // [!code highlight] - const parsed = stored.exists ? StoredHistorySchema.safeParse(stored.data) : undefined; - const messages: ChatMessage[] = parsed?.success ? [...parsed.data.messages] : []; - - const result = await generateText({ - model: anthropic('claude-haiku-4-5'), - system: 'You are a helpful assistant deployed across Slack and Discord.', - messages: [ - ...messages.slice(-20), - { role: 'user', content: text }, - ], - }); - - // Sliding window: 20 messages (10 turns), 24-hour TTL - messages.push({ role: 'user', content: text }); - messages.push({ role: 'assistant', content: result.text }); - await ctx.kv.set( // [!code highlight] - 'chat-sdk-conversations', - threadId, - { messages: messages.slice(-20) }, - { ttl: 86400 }, - ); - - return { response: result.text }; - }, -}); -``` - -The sliding window caps history at 20 messages (10 turns), keeping the LLM token budget bounded. Every conversation is browsable in the Agentuity dashboard under Key Value Stores, keyed by platform and thread ID. - - -`ctx.thread.state` uses cookie-based thread IDs, which webhook bots don't have. KV lets you use Chat SDK's `thread.id` as the key, gives 24-hour retention (vs. 1 hour for thread state), and provides dashboard visibility for inspecting what the bot remembers. - - -## Webhook Routing - -Incoming platform webhooks hit a single Agentuity route that dispatches to the correct Chat SDK adapter: - -```typescript title="src/api/index.ts" -import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; -import { bot } from '@lib/bot'; - -function isWebhookPlatform(platform: string): platform is keyof typeof bot.webhooks { - return Object.prototype.hasOwnProperty.call(bot.webhooks, platform); -} - -const api = new Hono() - .all('/webhooks/:platform', async (c) => { - const platform = c.req.param('platform'); - - if (!isWebhookPlatform(platform)) { - return c.json({ error: `Unknown platform: ${platform}` }, 404); - } - - const webhookHandler = bot.webhooks[platform]; - - return await webhookHandler(c.req.raw, { - waitUntil: (promise) => { // [!code highlight] - c.waitUntil(async () => { - await promise.catch((err) => { - c.var.logger.error(`Webhook processing error (${platform})`, { error: err }); - }); - }); - }, - }); - }); - -export default api; -``` - -Slack sends verification challenges and event payloads to `/api/webhooks/slack`. Discord sends interaction payloads to `/api/webhooks/discord`. Chat SDK handles signature verification and event parsing for each platform. - -## Discord Gateway - -Discord's Gateway API uses a persistent WebSocket connection for real-time events. Agentuity's long-running runtime keeps this connection alive without needing external process managers: - -```typescript title="app.ts" -import { createApp, registerShutdownHook } from '@agentuity/runtime'; -import api from './src/api/index'; -import chatAgent from './src/agent/chat/agent'; -import { bot } from '@lib/bot'; - -const app = await createApp({ - router: { path: '/api', router: api }, - agents: [chatAgent], -}); - -registerShutdownHook(async () => { - await bot.shutdown(); -}); - -if (process.env.DISCORD_BOT_TOKEN) { - await bot.initialize(); - const discord = bot.getAdapter('discord'); - - if (discord) { - const webhookUrl = `http://127.0.0.1:${process.env.PORT || '3500'}/api/webhooks/discord`; - const abortController = new AbortController(); - - const startListener = () => { - discord.startGatewayListener( - { waitUntil: (promise) => { /* auto-restart on expiry */ } }, - 24 * 60 * 60 * 1000, // 24-hour sessions - abortController.signal, - webhookUrl, - ); - }; - - startListener(); - registerShutdownHook(() => abortController.abort()); // [!code highlight] - } -} - -export default app; -``` - -The Gateway listener forwards Discord events to the local webhook endpoint. When the 24-hour session expires, it automatically restarts. `registerShutdownHook` cancels the listener during graceful shutdown. - -## Full Example - -[Chat SDK Integration](https://github.com/agentuity/examples/tree/main/integrations/chat-sdk): Slack and Discord adapters, AI chat agent, webhook routing, and Discord Gateway setup. - -Chat SDK also supports [GitHub, Teams, Google Chat, and Linear](https://chat-sdk.dev/docs/adapters) adapters with the same pattern. - -## Next Steps - -- [Key-Value Storage](/services/storage/key-value): Full KV API with namespaces, TTL, and dashboard access -- [AI Gateway](/agents/ai-gateway): Route LLM calls through a single API with observability -- [Standalone Execution](/agents/standalone-execution): Run agents from bots, CLI tools, and background workers -- [Chat with History](/cookbook/patterns/chat-with-history): The same conversation memory pattern using thread state diff --git a/docs/src/web/content/cookbook/integrations/claude-agent.mdx b/docs/src/web/content/cookbook/integrations/claude-agent.mdx index 772218721..186a987b5 100644 --- a/docs/src/web/content/cookbook/integrations/claude-agent.mdx +++ b/docs/src/web/content/cookbook/integrations/claude-agent.mdx @@ -1,190 +1,137 @@ --- -title: Using Claude Agent SDK with Agentuity -short_title: Claude Agent SDK -description: Build conversational code intelligence agents with Claude Agent SDK and Agentuity sandboxes +title: Claude Agent SDK +description: Run multi-turn Claude Agent sessions from a framework route with key-value session storage --- -[Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview) handles conversations and tool use: reading files, writing code, running multi-turn analysis. Agentuity adds the deployment runtime, [sandboxes](/services/sandbox/sdk-usage) for safe code execution, and [thread state](/agents/state-management) for conversation history. You write the agent logic, Agentuity handles the infrastructure. +The [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/typescript) wraps Claude with file tools, sub-agents, and durable session IDs. Drop it into a framework route when you want Claude to read files, write code, or run multi-turn analysis with conversation history. -## The Integration Pattern +```bash +bun add hono @anthropic-ai/claude-agent-sdk @agentuity/keyvalue@alpha @agentuity/schema@alpha +``` -Wrap a Claude Agent SDK `query()` call inside Agentuity's [`createAgent()`](/agents/creating-agents). Claude handles the LLM interaction and file tools. Agentuity provides schema validation, thread-isolated state, and sandbox execution. +## Run a Single Turn -Define schemas and create the agent handler: +`query()` returns an async iterator of SDK messages. Drain the iterator to get the final result message; it carries the assistant text plus the session ID. -```typescript title="src/agent/claude-code/index.ts" -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; +```typescript title="src/lib/code-assist.ts" import { query } from '@anthropic-ai/claude-agent-sdk'; import type { SDKMessage, SDKResultMessage } from '@anthropic-ai/claude-agent-sdk'; -interface StoredMessage { - readonly role: 'user' | 'assistant'; - readonly content: string; +interface AssistResult { + readonly response: string; + readonly sessionId: string; + readonly costUsd?: number; } -function buildPrompt(history: readonly StoredMessage[], prompt: string): string { - if (history.length === 0) { - return prompt; +export async function runCodeAssist(prompt: string, sessionId?: string): Promise { + const messages: SDKMessage[] = []; + for await (const message of query({ + prompt, + options: sessionId ? { resume: sessionId } : undefined, + })) { + messages.push(message); } - const previousTurns = history - .map((message) => `${message.role}: ${message.content}`) - .join('\n'); + const result = messages.find( + (message): message is SDKResultMessage => message.type === 'result' + ); - return `${previousTurns}\nuser: ${prompt}`; -} + if (!result) { + throw new Error('No result message returned from query()'); + } -function getResultMessage(messages: readonly SDKMessage[]): SDKResultMessage | undefined { - return messages.find((message): message is SDKResultMessage => message.type === 'result'); + if (result.subtype !== 'success') { + throw new Error(result.errors.join('\n') || 'Claude Agent SDK returned an error result'); + } + + return { + response: result.result, + sessionId: result.session_id, + costUsd: result.total_cost_usd, + }; } +``` -const AgentInput = s.object({ - prompt: s.string().describe('The user prompt to send to Claude'), -}); +The `resume` option carries on a previous session by ID. The first call creates a session; subsequent calls reuse it for cheap multi-turn conversations. -const AgentOutput = s.object({ - response: s.string(), - sessionId: s.string(), - threadId: s.string(), - costUsd: s.number().optional(), -}); +## Track Sessions Per Conversation -export default createAgent('claude-code', { - schema: { input: AgentInput, output: AgentOutput }, - handler: async (ctx, { prompt }) => { - ctx.logger.info('Claude Agent invoked', { promptLength: prompt.length }); - - const workspaceDir = join(tmpdir(), 'claude-code-workspaces', ctx.thread.id); - initWorkspace(workspaceDir); - - // Load conversation history from thread state - const history = (await ctx.thread.state.get('messages')) ?? []; // [!code highlight] - const fullPrompt = buildPrompt(history, prompt); - - const systemPrompt = [ - 'You are a helpful code assistant running inside an Agentuity agent.', - 'You have access to a workspace with sample TypeScript files.', - history.length > 0 - ? `\nThis is a continuing conversation (${history.length} messages so far).` - : '', - ].join('\n'); - - // Claude Agent SDK manages the conversation loop and tool calls - const collectedMessages: SDKMessage[] = []; - const q = query({ // [!code highlight] - prompt: fullPrompt, - options: { - systemPrompt, - allowedTools: ['Read', 'Write', 'Edit', 'Glob', 'Grep'], // [!code highlight] - permissionMode: 'dontAsk', - cwd: workspaceDir, - maxTurns: 10, - }, - }); - - for await (const msg of q) { - collectedMessages.push(msg); - } - - const resultMessage = getResultMessage(collectedMessages); - const responseText = resultMessage?.subtype === 'success' - ? resultMessage.result - : 'No response generated'; - - // Persist with a sliding window via thread state - await ctx.thread.state.push('messages', { role: 'user', content: prompt }, 20); - await ctx.thread.state.push('messages', { role: 'assistant', content: responseText }, 20); - - return { - response: responseText, - sessionId: ctx.sessionId, - threadId: ctx.thread.id, - ...(resultMessage ? { costUsd: resultMessage.total_cost_usd } : {}), - }; - }, -}); -``` +Map your app's conversation IDs to Claude session IDs in [key-value storage](/services/storage/key-value). The route looks up the session before calling `query()` and stores the latest one after each response. -Claude Agent SDK iterates through an async generator of messages. `allowedTools` pre-approves the listed tools, and `permissionMode: 'dontAsk'` denies tools that were not pre-approved instead of prompting. Each query is bounded by `maxTurns`, and Agentuity's [`ctx.thread.state`](/agents/state-management) persists conversation history across requests with a 20-message sliding window. +```typescript title="src/index.ts" +import { Hono } from 'hono'; +import { KeyValueClient } from '@agentuity/keyvalue'; +import { s } from '@agentuity/schema'; +import { runCodeAssist } from './lib/code-assist'; -## Sandbox Execution +const SESSION_NAMESPACE = 'claude-sessions'; -When a user asks to run code, the agent ships workspace files to an Agentuity [sandbox](/services/sandbox/sdk-usage) for isolated execution. Claude handles the analysis, Agentuity handles the execution: +const requestSchema = s.object({ + conversationId: s.string(), + prompt: s.string(), +}); -```typescript title="src/agent/claude-code/index.ts" -// Detect execution intent from the user's prompt -const wantsExecution = /\b(run|execute|test|try)\b/i.test(prompt); +const kv = new KeyValueClient(); +const app = new Hono(); -if (wantsExecution) { - const workspaceFiles = collectWorkspaceFiles(workspaceDir); - const mainFile = pickMainFile(workspaceFiles, prompt, collectedMessages); +app.post('/api/code-assist', async (c) => { + const body: unknown = await c.req.json(); + const input = requestSchema.parse(body); - if (workspaceFiles.length > 0 && mainFile) { - ctx.logger.info('Executing in sandbox', { mainFile, fileCount: workspaceFiles.length }); + const stored = await kv.get(SESSION_NAMESPACE, input.conversationId); + const previousSessionId = stored.exists ? stored.data : undefined; - const result = await ctx.sandbox.run({ // [!code highlight] - runtime: 'bun:1', - command: { - exec: ['bun', 'run', mainFile], - files: workspaceFiles, // [!code highlight] - }, - resources: { memory: '500Mi', cpu: '500m' }, - }); + const result = await runCodeAssist(input.prompt, previousSessionId); - executionResult = { - stdout: result.stdout, - stderr: result.stderr !== result.stdout ? result.stderr : undefined, - exitCode: result.exitCode, - }; - } -} + await kv.set(SESSION_NAMESPACE, input.conversationId, result.sessionId, { + ttl: 60 * 60 * 24 * 30, + }); + + return c.json({ + response: result.response, + conversationId: input.conversationId, + costUsd: result.costUsd, + }); +}); + +export default app; ``` -`ctx.sandbox.run()` creates an isolated environment with the Bun runtime, copies in the workspace files, and executes the specified file. Resources are capped so a single request cannot exhaust the sandbox. The exit code, stdout, and stderr come back as structured data. +The Claude session lives on Anthropic's side; only the session ID lives in your KV. That keeps storage cheap and lets the SDK reuse cached state automatically. -## Workspace Setup +## Workspaces -The agent seeds a per-thread temp directory with sample files so Claude has code to work with from the first message: +For agents that read or write files, give each conversation its own working directory. The SDK reads `cwd` from `query()` options. -```typescript title="src/agent/claude-code/index.ts" -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +```typescript +import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { SAMPLE_FILES } from './sample-files'; -function initWorkspace(workspaceDir: string): void { - if (!existsSync(workspaceDir)) { - mkdirSync(workspaceDir, { recursive: true }); - } - for (const file of SAMPLE_FILES) { - const filePath = join(workspaceDir, file.name); - if (!existsSync(filePath)) { - writeFileSync(filePath, file.content); - } - } +async function getWorkspace(conversationId: string): Promise { + const dir = join(tmpdir(), 'claude-workspaces', conversationId); + await mkdir(dir, { recursive: true }); + return dir; } -// One workspace per thread, so concurrent conversations stay isolated -const workspaceDir = join(tmpdir(), 'claude-code-workspaces', ctx.thread.id); -initWorkspace(workspaceDir); -``` +const cwd = await getWorkspace(input.conversationId); -Each thread gets its own directory keyed by `ctx.thread.id`, keeping concurrent conversations isolated. Files that Claude writes or edits during the session persist within the workspace for follow-up turns. +for await (const message of query({ + prompt: input.prompt, + options: { cwd, resume: previousSessionId }, +})) { + // ... +} +``` - -This integration requires an `ANTHROPIC_API_KEY` in your `.env` file. The Claude Agent SDK authenticates directly with Anthropic, separate from the [AI Gateway](/agents/ai-gateway). - +For deployed apps that need workspaces to outlive a process, write files to [object storage](/services/storage/object) instead of `tmpdir()` and stage them into the workspace each request. -## Full Example +## When to Reach for the Claude Agent SDK -[Claude Code Integration](https://github.com/agentuity/examples/tree/main/integrations/claude-code): complete project with chat frontend, API routes, and sample workspace files. +Pick this SDK when you want Claude's full agent loop with file tools, planning, and multi-turn caching. Pick the [Anthropic SDK Messages API](/cookbook/patterns/autonomous-research) when you want full control over `tool_use` blocks. Pick [AI SDK](/build/agents) for one provider-agnostic surface. ## Next Steps -- [Sandbox SDK Usage](/services/sandbox/sdk-usage): Full API for creating and managing sandboxes -- [State Management](/agents/state-management): Request, thread, and global state scopes -- [Streaming Responses](/agents/streaming-responses): Stream agent output to the frontend in real time -- [LLM as a Judge](/cookbook/patterns/llm-as-a-judge): Test and validate agent outputs +- [Build Agents](/build/agents): the plain-function pattern this page wraps +- [Chat with History](/cookbook/patterns/chat-with-history): a similar conversation pattern, without an external SDK +- [Sandbox SDK Usage](/services/sandbox/sdk-usage): isolate file-writing agents from the host diff --git a/docs/src/web/content/cookbook/integrations/langchain.mdx b/docs/src/web/content/cookbook/integrations/langchain.mdx index 0508afc85..9deff5d92 100644 --- a/docs/src/web/content/cookbook/integrations/langchain.mdx +++ b/docs/src/web/content/cookbook/integrations/langchain.mdx @@ -1,39 +1,37 @@ --- -title: Using LangChain with Agentuity -short_title: LangChain -description: Build LangChain agents with Agentuity's deployment runtime, persistent storage, and observability +title: LangChain +description: Run a LangChain ReAct agent inside a framework route with tools and middleware --- -[LangChain](https://js.langchain.com) provides agent primitives, chains, and tool orchestration, but deploying and running those agents requires infrastructure: state management, credential routing, observability. Agentuity handles that layer. Write your agent logic with LangChain, deploy it on Agentuity with built-in storage, logging, and an [AI gateway](/agents/ai-gateway). +[LangChain.js](https://js.langchain.com) ships an agent runtime, tool definitions, and middleware. Drop a LangChain agent into a Hono route when you already have LangChain code, or when its middleware model fits the way you want to structure tool errors and retries. -## ReAct Agent with Tools +```bash +bun add hono langchain @langchain/openai @langchain/core @agentuity/schema@alpha zod +``` -Create a [LangChain ReAct agent](https://js.langchain.com/docs/concepts/agents) inside an Agentuity handler. LangChain owns the agent loop, Agentuity owns the infrastructure. +## Define the Agent -Define your tools and agent with LangChain's standard APIs: +The current LangChain entrypoint is `createAgent` from `langchain`. Tools come from `@langchain/core/tools`; the chat model comes from a provider package. -```typescript title="src/agent/basic/index.ts" +```typescript title="src/lib/research-agent.ts" import { - AIMessage, - createAgent as createLangChainAgent, + createAgent, createMiddleware, - tool, ToolMessage, } from 'langchain'; +import { tool } from '@langchain/core/tools'; import { ChatOpenAI } from '@langchain/openai'; -import * as z from 'zod'; +import { z } from 'zod'; -// LangChain tools use Zod for input validation const search = tool( async ({ query }) => `Results for: ${query}`, { name: 'search', description: 'Search for information', schema: z.object({ query: z.string().describe('The search query') }), - }, + } ); -// Middleware catches tool errors so the agent can recover const handleToolErrors = createMiddleware({ name: 'HandleToolErrors', wrapToolCall: async (request, handler) => { @@ -41,9 +39,7 @@ const handleToolErrors = createMiddleware({ return await handler(request); } catch (error) { const toolCallId = request.toolCall.id; - if (!toolCallId) { - throw error; - } + if (!toolCallId) throw error; return new ToolMessage({ content: `Tool error: ${String(error)}`, @@ -53,7 +49,7 @@ const handleToolErrors = createMiddleware({ }, }); -const langchainAgent = createLangChainAgent({ +export const researchAgent = createAgent({ model: new ChatOpenAI({ model: 'gpt-5.4', temperature: 0.1 }), tools: [search], middleware: [handleToolErrors], @@ -61,136 +57,75 @@ const langchainAgent = createLangChainAgent({ }); ``` -Then wrap the LangChain agent with Agentuity's `createAgent()` for deployment, schemas, and observability: +The middleware is optional but useful: a thrown tool error becomes a `ToolMessage` the agent can recover from instead of crashing the loop. -```typescript title="src/agent/basic/index.ts" -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; +## Wire the Route -export default createAgent('basic', { - description: 'LangChain ReAct agent with tools and error handling', - schema: { - input: s.object({ message: s.string() }), - output: s.object({ response: s.string() }), - }, - handler: async (ctx, { message }) => { - ctx.logger.info('Invoking LangChain agent', { message }); // [!code highlight] - - const result = await langchainAgent.invoke({ - messages: [{ role: 'user', content: message }], - }); - - const lastAiMessage = [...result.messages] - .reverse() - .find((message): message is AIMessage => message instanceof AIMessage); - - return { - response: typeof lastAiMessage?.content === 'string' - ? lastAiMessage.content - : 'No response generated', - }; - }, -}); -``` +LangChain agents implement the LangGraph runnable interface. `invoke()` returns the final state; the last message is the assistant response. -- LangChain tools use `tool()` with Zod schemas, middleware uses `createMiddleware()` with `wrapToolCall` hooks -- `langchainAgent.invoke()` returns a `messages` array containing the full reasoning trace -- Extract the final AI response by finding the last `AIMessage` in the array +```typescript title="src/index.ts" +import { Hono } from 'hono'; +import { HumanMessage } from '@langchain/core/messages'; +import { s } from '@agentuity/schema'; +import { researchAgent } from './lib/research-agent'; - -When deployed to Agentuity, model credentials are managed through the [AI Gateway](/agents/ai-gateway). No API keys needed in your code. - +const requestSchema = s.object({ + message: s.string(), +}); -## Streaming with Timeline +const app = new Hono(); -Use `agent.stream()` with `streamMode: 'values'` to iterate over state snapshots as the agent reasons, calls tools, and generates responses. +app.post('/api/research', async (c) => { + const body: unknown = await c.req.json(); + const { message } = requestSchema.parse(body); -```typescript -// ... langchainAgent and tools defined above -import { AIMessage, ToolMessage, createAgent as createLangChainAgent, tool } from 'langchain'; -import { ChatOpenAI } from '@langchain/openai'; -import { HumanMessage } from '@langchain/core/messages'; + const result = await researchAgent.invoke({ + messages: [new HumanMessage(message)], + }); -const langchainAgent = createLangChainAgent({ - model: new ChatOpenAI({ model: 'gpt-5.4', temperature: 0.3 }), - tools: [search, calculate, getTime], + const last = result.messages.at(-1); + return c.json({ + response: typeof last?.content === 'string' + ? last.content + : JSON.stringify(last?.content ?? ''), + }); }); -// Stream the agent execution, capturing each step -const stream = await langchainAgent.stream( - { messages: [new HumanMessage(message)] }, - { streamMode: 'values' }, // [!code highlight] -); - -for await (const chunk of stream) { - const lastMessage = chunk.messages.at(-1); - if (!lastMessage) { - continue; - } - - if (lastMessage instanceof AIMessage && (lastMessage.tool_calls?.length ?? 0) > 0) { - // Tool call in progress: agent decided to use a tool - } else if (lastMessage instanceof ToolMessage) { - // Tool result received: observation ready for the next reasoning step - } else if (lastMessage instanceof AIMessage) { - // Final AI response: no more tool calls - } -} +export default app; ``` -Each snapshot includes the full message history. Tool calls appear as `AIMessage` instances with a non-empty `tool_calls` array, while the final response has an empty array. +## Streaming -## Structured Output - -Use `responseFormat` with a Zod schema when the agent needs to return typed data instead of prose. LangChain validates the result and returns it on `structuredResponse`. +`stream()` returns an iterator of state updates. Forward chunks to the client over Server-Sent Events when the route is meant to render incrementally. ```typescript -// ... langchainAgent and tools defined above -import { createAgent as createLangChainAgent } from 'langchain'; -import { ChatOpenAI } from '@langchain/openai'; -import { z } from 'zod'; - -const ContactInfoSchema = z.object({ - name: z.string().describe('Full name of the person'), - email: z.string().describe('Email address'), - company: z.string().describe('Company name'), - role: z.string().describe('Job title'), +app.post('/api/research-stream', async (c) => { + const { message } = requestSchema.parse(await c.req.json()); + + return new Response( + new ReadableStream({ + async start(controller) { + for await (const chunk of await researchAgent.stream({ + messages: [new HumanMessage(message)], + })) { + controller.enqueue(`data: ${JSON.stringify(chunk)}\n\n`); + } + controller.close(); + }, + }), + { headers: { 'content-type': 'text/event-stream' } } + ); }); - -const contactAgent = createLangChainAgent({ - model: new ChatOpenAI({ model: 'gpt-5.4' }), - tools: [search], - responseFormat: ContactInfoSchema, // [!code highlight] - systemPrompt: 'Find contact details and return only the requested fields.', -}); - -const result = await contactAgent.invoke({ - messages: [{ role: 'user', content: 'Find contact details for Alice at Acme' }], -}); - -const contact = result.structuredResponse; // [!code highlight] -// contact.name, contact.email, contact.company are typed strings ``` -The schema belongs to the LangChain agent, while `@agentuity/schema` still validates the Agentuity request and response payloads. - - -LangChain middleware layers compose in order. Stack multiple layers for dynamic model selection, role-based tool filtering, and tool-call interception. See the [Dynamic Tools](https://github.com/agentuity/examples/tree/main/integrations/langchain/dynamic-tools) and [Dynamic Model](https://github.com/agentuity/examples/tree/main/integrations/langchain/dynamic-model) examples. - - -## Full Examples +## When to Reach for LangChain -Explore complete working examples for each pattern: +Pick LangChain when you already have LangChain or LangGraph code, or when the middleware and graph model give you something AI SDK does not. Pick raw [AI SDK](/build/agents) when you want fewer layers and provider-agnostic tool calling. -- [Basic ReAct Agent](https://github.com/agentuity/examples/tree/main/integrations/langchain/basic-agent): tools, model config, error handling middleware -- [Streaming Agent](https://github.com/agentuity/examples/tree/main/integrations/langchain/streaming-agent): `agent.stream()` with timeline visualization -- [Dynamic Tools](https://github.com/agentuity/examples/tree/main/integrations/langchain/dynamic-tools): role-based tool filtering with composed middleware -- [Dynamic Model](https://github.com/agentuity/examples/tree/main/integrations/langchain/dynamic-model): runtime model selection based on conversation complexity -- [Structured Output](https://github.com/agentuity/examples/tree/main/integrations/langchain/structured-agent-output): `responseFormat` and Zod schemas -- [System Prompt](https://github.com/agentuity/examples/tree/main/integrations/langchain/system-prompt): static prompts, dynamic prompt middleware, custom state schemas +Run locally with `agentuity dev` to use [AI Gateway](/services/ai-gateway) env wiring for `ChatOpenAI`. For deployed apps, set provider keys directly unless you have verified the project receives the gateway env your SDK path needs. Setting `OPENAI_API_KEY` keeps `ChatOpenAI` on the direct provider. ## Next Steps -- [Creating Agents](/agents/creating-agents): Agentuity agent patterns and schemas -- [AI Gateway](/agents/ai-gateway): Managed model credentials across providers -- [State Management](/agents/state-management): Persist conversation history across requests +- [Build Agents](/build/agents): the plain-function pattern this page wraps +- [Tool Calling](/build/tool-calling): the AI SDK 6 path with provider-agnostic tool calls +- [Autonomous Research](/cookbook/patterns/autonomous-research): a hand-rolled tool loop using the Anthropic SDK diff --git a/docs/src/web/content/cookbook/integrations/mastra.mdx b/docs/src/web/content/cookbook/integrations/mastra.mdx index f89d2c88f..23839abd4 100644 --- a/docs/src/web/content/cookbook/integrations/mastra.mdx +++ b/docs/src/web/content/cookbook/integrations/mastra.mdx @@ -1,247 +1,126 @@ --- -title: Using Mastra with Agentuity -short_title: Mastra -description: Deploy Mastra agents on Agentuity with persistent state, observability, and the AI Gateway +title: Mastra +description: Run a Mastra Agent inside a framework route with key-value memory --- -[Mastra](https://mastra.ai/docs) gives you the building blocks for AI agents: tools, structured output, multi-agent workflows. But frameworks don't come with a place to run. Agentuity provides the deployment runtime, persistent state, and observability so you can focus on agent logic instead of infrastructure. Define your agents in Mastra, deploy them on Agentuity. +[Mastra](https://mastra.ai/docs) gives you a typed `Agent` class with `.generate()`, `.stream()`, and tool composition. Drop a Mastra agent into a Hono route, store conversation history in [key-value storage](/services/storage/key-value), and deploy with the rest of your app. -## The Integration Pattern +```bash +bun add hono @mastra/core @agentuity/keyvalue@alpha @agentuity/schema@alpha +``` -Wrap a Mastra `Agent` in Agentuity's `createAgent()`. Mastra runs the LLM calls and tool orchestration. Agentuity provides [schemas](/agents/schema-libraries), [thread-isolated state](/agents/state-management), [logging](/services/observability/logging) via `ctx.logger`, and deployment. +## Define the Agent -Create the Mastra agent with your model and instructions: +`Agent` accepts a model spec, instructions, and an optional tool map. Mastra resolves provider strings (`openai/...`, `anthropic/...`) through its built-in registry. Run locally with `agentuity dev` when you want those provider calls to use [AI Gateway](/services/ai-gateway) env wiring. -```typescript +```typescript title="src/lib/chat-agent.ts" import { Agent } from '@mastra/core/agent'; -// Mastra handles the agent logic and LLM interaction -const chatAgent = new Agent({ - id: 'chat-agent', +export const chatAgent = new Agent({ + id: 'chat', name: 'Chat Agent', - instructions: 'You are a helpful assistant with memory.', - model: 'openai/gpt-5.4', + instructions: 'You are a concise product support assistant.', + model: 'openai/gpt-5.4-mini', }); ``` -Then wrap it with Agentuity's `createAgent()` for deployment, state management, and observability: +## Wire the Route -```typescript -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; +Validate the request, load the conversation, call `agent.generate()`, store the new turn. The route is thin; the Mastra agent owns the model call. -// Agentuity handles deployment, schemas, and state -export default createAgent('chat', { - schema: { - input: s.object({ message: s.string() }), - output: s.object({ response: s.string() }), - }, - handler: async (ctx, { message }) => { - // Load history from thread state, isolated per conversation - const history = (await ctx.thread.state.get<{ role: string; content: string }[]>('messages')) ?? []; - - const result = await chatAgent.generate([ - ...history, - { role: 'user', content: message }, - ]); +```typescript title="src/index.ts" +import { Hono } from 'hono'; +import { KeyValueClient } from '@agentuity/keyvalue'; +import { s } from '@agentuity/schema'; +import { chatAgent } from './lib/chat-agent'; - // Persist with a 20-message sliding window - await ctx.thread.state.push('messages', { role: 'user', content: message }, 20); // [!code highlight] - await ctx.thread.state.push('messages', { role: 'assistant', content: result.text }, 20); // [!code highlight] +const HISTORY_NAMESPACE = 'mastra-chat'; +const HISTORY_LIMIT = 20; - return { response: result.text }; - }, +const messageSchema = s.object({ + role: s.enum(['user', 'assistant']), + content: s.string(), }); -``` -Mastra handles the LLM interaction and tool orchestration. Agentuity provides per-conversation state via `ctx.thread.state` (with a sliding window to cap history size), logs with searchable context, and deployment. +const requestSchema = s.object({ + conversationId: s.string(), + message: s.string(), +}); - -When deployed on Agentuity, Mastra's `openai/gpt-5.4` model string routes through the [AI Gateway](/agents/ai-gateway) unless you provide your own OpenAI key. No bridge file is needed. - +const historySchema = s.array(messageSchema); - -Some older Mastra examples in the examples repo still include `src/lib/gateway.ts` to set `OPENAI_API_KEY` and `OPENAI_BASE_URL` explicitly. That helper is optional with the current SDK when you run through `agentuity dev` or a deployed Agentuity build. - +type ChatMessage = s.infer; -## Tool Calling +const kv = new KeyValueClient(); +const app = new Hono(); -Mastra's `createTool()` defines typed tools with Zod schemas. The agent calls them automatically based on the user's message. Agentuity wraps the agent for deployment and schema validation. +app.post('/api/chat', async (c) => { + const body: unknown = await c.req.json(); + const input = requestSchema.parse(body); -Define a tool with `createTool()` and attach it to a Mastra agent: + const stored = await kv.get(HISTORY_NAMESPACE, input.conversationId); + const history: readonly ChatMessage[] = stored.exists + ? historySchema.parse(stored.data) + : []; -```typescript title="src/agent/weather/index.ts" -import { createTool } from '@mastra/core/tools'; -import { Agent } from '@mastra/core/agent'; -import { z } from 'zod'; + const userMessage: ChatMessage = { role: 'user', content: input.message }; + const result = await chatAgent.generate([...history, userMessage]); -// Validate API responses before returning tool output -const GeoResponse = z.object({ - results: z.array(z.object({ - latitude: z.number(), - longitude: z.number(), - })).optional(), -}); + const assistantMessage: ChatMessage = { + role: 'assistant', + content: result.text, + }; -const WeatherResponse = z.object({ - current: z.object({ temperature_2m: z.number() }), -}); + const next = [...history, userMessage, assistantMessage].slice(-HISTORY_LIMIT); + await kv.set(HISTORY_NAMESPACE, input.conversationId, next, { + ttl: 60 * 60 * 24 * 30, + }); -// Tool input schema is shown to the LLM for parameter selection -const weatherTool = createTool({ - id: 'get-weather', - description: 'Fetches current weather for a location', - inputSchema: z.object({ - location: z.string().describe('City or location name'), - }), - execute: async ({ location }) => { - const geo = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(location)}&count=1`); - if (!geo.ok) { - return `Unable to look up ${location}: ${geo.status}`; - } - - const geoJson: unknown = await geo.json().catch(() => undefined); - const geoData = GeoResponse.safeParse(geoJson); - if (!geoData.success) { - return `Unable to read location data for ${location}`; - } - - const firstResult = geoData.data.results?.[0]; - - if (!firstResult) { - return `No weather data found for ${location}`; - } - - const { latitude, longitude } = firstResult; - const weather = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m`); - if (!weather.ok) { - return `Unable to fetch weather for ${location}: ${weather.status}`; - } - - const weatherJson: unknown = await weather.json().catch(() => undefined); - const data = WeatherResponse.safeParse(weatherJson); - if (!data.success) { - return `Unable to read weather data for ${location}`; - } - - return `${location}: ${data.data.current.temperature_2m}°C`; - }, + return c.json({ + conversationId: input.conversationId, + message: assistantMessage, + messageCount: next.length, + }); }); -const weatherAgent = new Agent({ - id: 'weather-agent', - instructions: 'Use the get-weather tool when users ask about weather.', - model: 'openai/gpt-5.4', - tools: { weatherTool }, // Mastra handles the function calling loop -}); +export default app; ``` -Then create the Agentuity handler that calls the agent and returns the result: +## Tools -```typescript title="src/agent/weather/index.ts" -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; +Mastra tools are typed with the same `createTool()` pattern Mastra users already know. Pass them through the `tools` field on `new Agent({ ... })`. -export default createAgent('weather', { - schema: { - input: s.object({ message: s.string() }), - output: s.object({ response: s.string() }), - }, - handler: async (ctx, { message }) => { - ctx.logger.info('Weather request', { message }); - const result = await weatherAgent.generate(message); - return { response: result.text }; - }, -}); -``` - -- Mastra tools use `createTool()` with Zod schemas for parameter validation -- `result.text` contains the LLM's final response after all tool calls complete -- `result.usage` contains token usage when the model provider returns it - -## Structured Output - -When you need the LLM to return data in a specific shape, use Mastra's `structuredOutput` with a [Zod](https://zod.dev/) schema. The LLM response is validated and parsed before your code sees it. - -Define the Zod schema that describes the expected output shape: - -```typescript title="src/agent/day-planner/index.ts" -import { Agent } from '@mastra/core/agent'; +```typescript +import { createTool } from '@mastra/core/tools'; import { z } from 'zod'; -// Zod schema: validates the LLM's structured response -const DayPlanSchema = z.object({ - plan: z.array(z.object({ - name: z.string().describe('Time block name'), - activities: z.array(z.object({ - name: z.string(), - startTime: z.string().describe('HH:MM format'), - endTime: z.string().describe('HH:MM format'), - description: z.string(), - priority: z.enum(['high', 'medium', 'low']), - })), - })), - summary: z.string(), +const lookupOrder = createTool({ + id: 'lookup-order', + description: 'Look up an order by ID', + inputSchema: z.object({ orderId: z.string() }), + execute: async (input) => { + // call your database, API, etc. + return { status: 'shipped', orderId: input.orderId }; + }, }); -const plannerAgent = new Agent({ - id: 'day-planner', - instructions: 'Create structured daily plans from descriptions.', +export const supportAgent = new Agent({ + id: 'support', + name: 'Order Support', + instructions: 'Help customers with order questions. Use lookup-order when asked about a specific order.', model: 'openai/gpt-5.4', + tools: { lookupOrder }, }); ``` -Then pass the schema to `generate()` via `{ structuredOutput: { schema } }` and access the parsed result via `result.object`: - -```typescript title="src/agent/day-planner/index.ts" -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; - -export default createAgent('day-planner', { - schema: { - input: s.object({ prompt: s.string() }), - output: s.object({ plan: s.unknown(), summary: s.string() }), - }, - handler: async (ctx, { prompt }) => { - // structuredOutput returns a typed, validated object - const result = await plannerAgent.generate(prompt, { - structuredOutput: { schema: DayPlanSchema }, // [!code highlight] - }); - - // result.object is typed from DayPlanSchema - const plan = result.object; // [!code highlight] - ctx.logger.info('Plan generated', { - blocks: plan?.plan.length, - activities: plan?.plan.reduce((sum, b) => sum + b.activities.length, 0), - }); - - return { plan: plan?.plan, summary: plan?.summary ?? '' }; - }, -}); -``` - -Access the parsed output via `result.object` instead of `result.text`. The two schema layers serve different purposes: Zod controls what the LLM returns, while `@agentuity/schema` validates the HTTP request and response payloads. - - -Use Zod for Mastra's `structuredOutput` (LLM validation) and `@agentuity/schema` for Agentuity's I/O layer (API validation). They serve different purposes: Zod tells the LLM what shape to return, while `s` validates HTTP request/response payloads. - - -## Full Examples +The Mastra tool loop runs entirely inside `agent.generate()`. The route still owns HTTP and storage. -Each example is a complete project with agent code, a React frontend, and API routes: +## When to Reach for Mastra -| Example | Pattern | Source | -|---------|---------|--------| -| Agent Memory | Conversation history with sliding window | [agent-memory](https://github.com/agentuity/examples/tree/main/integrations/mastra/agent-memory) | -| Using Tools | Tool calling with real APIs | [using-tools](https://github.com/agentuity/examples/tree/main/integrations/mastra/using-tools) | -| Structured Output | Type-safe LLM responses with Zod | [structured-output](https://github.com/agentuity/examples/tree/main/integrations/mastra/structured-output) | -| Agent Approval | Human-in-the-loop tool approval | [agent-approval](https://github.com/agentuity/examples/tree/main/integrations/mastra/agent-approval) | -| Network Agent | Multi-agent routing and delegation | [network-agent](https://github.com/agentuity/examples/tree/main/integrations/mastra/network-agent) | -| Network Approval | Approval flows in multi-agent networks | [network-approval](https://github.com/agentuity/examples/tree/main/integrations/mastra/network-approval) | +Pick Mastra when you want its agent abstractions (workflows, multi-agent handoffs, structured output via Mastra primitives) and you do not want to build them on AI SDK directly. Pick raw [AI SDK](/build/agents) when you want fewer layers. ## Next Steps -- [State Management](/agents/state-management): All state scopes (request, thread, global) -- [AI Gateway](/agents/ai-gateway): Provider configuration and supported models -- [LLM as a Judge](/cookbook/patterns/llm-as-a-judge): Test and validate agent outputs -- [Chat with History](/cookbook/patterns/chat-with-history): Same pattern using the Vercel AI SDK directly +- [Build Agents](/build/agents): the plain-function pattern this page wraps +- [Chat with History](/cookbook/patterns/chat-with-history): the same KV layout, without Mastra +- [AI Gateway](/services/ai-gateway): local dev routing and deployed provider env choices diff --git a/docs/src/web/content/cookbook/integrations/meta.json b/docs/src/web/content/cookbook/integrations/meta.json index f62967dd2..b5b4085db 100644 --- a/docs/src/web/content/cookbook/integrations/meta.json +++ b/docs/src/web/content/cookbook/integrations/meta.json @@ -1,13 +1,4 @@ { "title": "Integrations", - "pages": [ - "mastra", - "langchain", - "openai-agents", - "claude-agent", - "chat-sdk", - "nextjs", - "tanstack-start", - "turborepo" - ] + "pages": ["mastra", "openai-agents", "claude-agent", "langchain"] } diff --git a/docs/src/web/content/cookbook/integrations/nextjs.mdx b/docs/src/web/content/cookbook/integrations/nextjs.mdx deleted file mode 100644 index 80fea62c5..000000000 --- a/docs/src/web/content/cookbook/integrations/nextjs.mdx +++ /dev/null @@ -1,174 +0,0 @@ ---- -title: Adding Agentuity to a Next.js App -short_title: Next.js -description: Connect a Next.js frontend to an Agentuity backend using rewrites and direct router types ---- - -Already have a [Next.js](https://nextjs.org/docs) app? Keep the frontend where it is and run Agentuity in an `agentuity/` subdirectory. Next.js rewrites `/api/*` to the Agentuity dev server, and your frontend imports the backend router type directly for `hc()`. - -## Project Structure - -```text -my-nextjs-app/ -├── app/ -│ ├── components/EchoDemo.tsx # Client component using hc() -│ └── page.tsx -├── agentuity/ -│ ├── app.ts # createApp({ router, agents }) -│ ├── src/agent/echo/agent.ts -│ └── src/api/index.ts # Exports ApiRouter -├── next.config.ts # Rewrites /api/* to Agentuity -└── package.json -``` - -## Rewrite `/api` to Agentuity - -Add a rewrite that proxies API traffic to `agentuity dev`. The SDK testing app runs Next.js on `3001` and Agentuity on `3501` to avoid conflicts: - -```typescript title="next.config.ts" -import type { NextConfig } from 'next'; -import { join } from 'path'; - -const nextConfig: NextConfig = { - // Only needed in monorepo/workspace setups where Next.js must trace - // dependencies above the app directory - outputFileTracingRoot: join(__dirname, '../../../..'), - - async rewrites() { - return [ - { - source: '/api/:path*', - destination: 'http://localhost:3501/api/:path*', // [!code highlight] - }, - ]; - }, -}; - -export default nextConfig; -``` - - -This pattern does not use `@agentuity/routes` or `src/generated/routes.ts`. The frontend imports `type ApiRouter` directly from the backend source file, and `hc()` infers requests from that type. - - -## Import the Router Type Directly - -Export a router type from the backend: - -```typescript title="agentuity/src/api/index.ts" -import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; -import echoAgent from '@agents/echo/agent'; - -const router = new Hono() - .get('/health', (c) => { - return c.json({ status: 'ok', timestamp: new Date().toISOString() }); - }) - .post('/echo', echoAgent.validator(), async (c) => { - const input = c.req.valid('json'); - return c.json(await echoAgent.run(input)); - }); - -export type ApiRouter = typeof router; // [!code highlight] -export default router; -``` - -Use that type in your Next.js client component: - -```tsx title="app/components/EchoDemo.tsx" -'use client'; - -import { hc } from 'hono/client'; // [!code highlight] -import type { ApiRouter } from '../../agentuity/src/api/index'; // [!code highlight] -import { useState } from 'react'; - -const client = hc('/api'); // [!code highlight] - -export default function EchoDemo() { - const [message, setMessage] = useState('Hello from Next.js!'); - const [response, setResponse] = useState<{ echo: string; timestamp: string } | null>(null); - const [error, setError] = useState(null); - - const sendEcho = async () => { - setError(null); - - const res = await client.echo.$post({ json: { message } }); - if (!res.ok) { - setError(`Echo request failed with ${res.status}`); - return; - } - - setResponse(await res.json()); - }; - - return ( -
- setMessage(e.target.value)} - value={message} - /> - - {error &&

{error}

} - {response &&

{response.echo}

} -
- ); -} -``` - - -Use `import type { ApiRouter } ...` so the backend file is erased from the client bundle while `hc()` still gets full RPC inference. - - - -Plain `hc()` calls do not require `AgentuityProvider`. Keep the client component focused on the typed Hono client unless you already depend on `@agentuity/react`. - - -## Running Locally - -Run from the project root (not from inside `agentuity/`): - -```bash -bun install -bun run dev -``` - -- Frontend: `http://localhost:3001` -- Backend: `http://localhost:3501` - -`bun run dev` starts both runtimes. There is no separate client route-generation step in v2. The frontend imports the router type from source, and `agentuity dev` serves the backend. - -## Deployment - -If you keep same-origin routing in production, keep `hc('/api')` and route `/api/*` to your Agentuity service at the host or CDN layer. - -If the frontend and backend live on different origins, point `hc()` at the deployed backend instead: - -```tsx -const agentuityBaseUrl = process.env.NEXT_PUBLIC_AGENTUITY_BASE_URL; -if (!agentuityBaseUrl) { - throw new Error('NEXT_PUBLIC_AGENTUITY_BASE_URL is required'); -} - -const client = hc(new URL('/api', agentuityBaseUrl).toString()); -``` - -`NEXT_PUBLIC_AGENTUITY_BASE_URL` must be a full absolute URL, including the scheme, such as `https://my-app.agentuity.run`. `new URL()` throws if you pass a bare host or a relative path. - -`/api` replaces any path suffix in `NEXT_PUBLIC_AGENTUITY_BASE_URL`. If your backend is mounted under a subpath, preserve it with a relative URL segment: - -```tsx -const baseUrl = new URL(agentuityBaseUrl); -if (!baseUrl.pathname.endsWith('/')) { - baseUrl.pathname += '/'; -} - -const client = hc(new URL('api', baseUrl).toString()); -``` - -Then enable CORS in [App Configuration](/get-started/app-configuration). - -## Next Steps - -- [RPC Client](/frontend/rpc-client): More `hc()` patterns, custom fetch, and error handling -- [Provider Setup](/frontend/provider-setup): Add auth, analytics, or WebRTC hooks -- [HTTP Routes](/routes/http): Define the backend API router you export to the frontend diff --git a/docs/src/web/content/cookbook/integrations/openai-agents.mdx b/docs/src/web/content/cookbook/integrations/openai-agents.mdx index 3ca6a2ee0..d94390687 100644 --- a/docs/src/web/content/cookbook/integrations/openai-agents.mdx +++ b/docs/src/web/content/cookbook/integrations/openai-agents.mdx @@ -1,18 +1,19 @@ --- -title: Using OpenAI Agents SDK with Agentuity -short_title: OpenAI Agents SDK -description: Run OpenAI Agents SDK tool calling, handoffs, and structured output on Agentuity's deployment runtime +title: OpenAI Agents SDK +description: Run an OpenAI Agents SDK agent loop inside a framework route --- -The [OpenAI Agents SDK](https://github.com/openai/openai-agents-js) handles tool calling, handoffs, and structured output. Agentuity gives those agents a place to run: deployment, persistent state, observability, and cloud services like storage and queues. The SDK manages the agent loop, Agentuity manages everything around it. +The [OpenAI Agents SDK](https://openai.github.io/openai-agents-js/) provides a small typed `Agent` class plus a `run()` function that drives the tool-calling loop. Drop it into a Hono route when you want an OpenAI-flavored agent without writing the loop yourself. -## Tool Calling Agent +```bash +bun add hono @openai/agents @agentuity/schema@alpha zod +``` -Define tools with the OpenAI Agents SDK `tool()` function and run them inside an Agentuity handler. The SDK manages the ReAct loop automatically. +## Define the Agent and Tools -Define your tools using `tool()` with Zod parameter schemas: +Tools use Zod schemas for parameters. Each tool has a `name`, `description`, parameters schema, and an `execute` function. -```typescript title="src/agent/tool-calling/index.ts" +```typescript title="src/lib/research-agent.ts" import { Agent, tool, setTracingDisabled } from '@openai/agents'; import { z } from 'zod'; @@ -24,12 +25,10 @@ const search = tool({ parameters: z.object({ query: z.string().describe('The search query'), }), - execute: async ({ query }) => { - return `Results for: ${query}`; - }, + execute: async ({ query }) => `Results for: ${query}`, }); -const assistant = new Agent({ +export const researchAgent = new Agent({ name: 'Research Assistant', instructions: 'You are a helpful assistant. Be concise.', model: 'gpt-5.4', @@ -37,189 +36,73 @@ const assistant = new Agent({ }); ``` -Create an OpenAI agent and wrap it with [`createAgent()`](/agents/creating-agents): - -```typescript title="src/agent/tool-calling/index.ts" -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; -import { run } from '@openai/agents'; - -export default createAgent('tool-calling', { - description: 'OpenAI Agents SDK with function tools', - schema: { - input: s.object({ message: s.string() }), - output: s.object({ response: s.string() }), - }, - handler: async (ctx, { message }) => { - ctx.logger.info('Running OpenAI agent', { message }); - - // run() executes the full ReAct loop - const result = await run(assistant, message); // [!code highlight] - - return { - response: typeof result.finalOutput === 'string' - ? result.finalOutput - : 'No response generated', - }; - }, -}); -``` - -- OpenAI Agents SDK uses `parameters` for tool schemas, while LangChain uses `schema` -- `run(agent, input)` executes the full ReAct loop automatically, including all tool calls -- `result.finalOutput` contains the agent's final response; `result.newItems` contains the full execution trace - - -The OpenAI Agents SDK enables tracing by default in server runtimes. If Agentuity should be your trace source, set `OPENAI_AGENTS_DISABLE_TRACING=1` or call `setTracingDisabled(true)` during process startup. If you instantiate `Runner` directly instead of using the `run()` helper, pass `new Runner({ tracingDisabled: true })`. - - -## Agent Handoffs +`setTracingDisabled(true)` skips the SDK's local tracing exporter. Remove that line if you want the SDK's traces. -Use the SDK's `handoffs` array and `handoff()` function to route requests between specialist agents. A triage agent decides which specialist handles each request. +## Wire the Route -```typescript title="src/agent/handoffs/index.ts" -import { Agent, run, handoff } from '@openai/agents'; -import { z } from 'zod'; +`run(agent, message)` runs the full ReAct loop. The route validates, runs, returns. -// ... tool definitions above (lookupInvoice, processRefund) +```typescript title="src/index.ts" +import { Hono } from 'hono'; +import { run } from '@openai/agents'; +import { s } from '@agentuity/schema'; +import { researchAgent } from './lib/research-agent'; -// Specialist agents with focused instructions -const billingAgent = new Agent({ - name: 'Billing Agent', - instructions: 'Help with invoice lookups and payment status.', - model: 'gpt-5.4', - tools: [lookupInvoice], +const requestSchema = s.object({ + message: s.string(), }); -const refundAgent = new Agent({ - name: 'Refund Agent', - instructions: 'Process refund requests immediately.', - model: 'gpt-5.4', - tools: [processRefund], -}); +const app = new Hono(); -// Typed escalation data for the refund handoff -const EscalationData = z.object({ - reason: z.string().describe('Why this is being escalated'), -}); +app.post('/api/research', async (c) => { + const body: unknown = await c.req.json(); + const { message } = requestSchema.parse(body); -function createTriageAgent(onRefundEscalation: (reason: string) => void) { - return Agent.create({ // [!code highlight] - name: 'Triage Agent', - instructions: `Route requests to the right specialist: -- Billing questions → Billing Agent -- Refund requests → Refund Agent (use escalate_to_refund)`, - model: 'gpt-5.4', - handoffs: [ - billingAgent, // basic handoff - handoff(refundAgent, { // [!code highlight] - onHandoff: (_ctx, input) => { - if (input?.reason) { - onRefundEscalation(input.reason); - } - }, - inputType: EscalationData, - toolNameOverride: 'escalate_to_refund', - }), - ], - }); -} -``` + const result = await run(researchAgent, message); -Inside the request handler, track which specialist handled the request using `result.lastAgent`: + return c.json({ + response: typeof result.finalOutput === 'string' + ? result.finalOutput + : JSON.stringify(result.finalOutput), + }); +}); -```typescript -const refundEscalations: string[] = []; -const triageAgent = createTriageAgent((reason) => refundEscalations.push(reason)); -const result = await run(triageAgent, message); -const handledBy = result.lastAgent?.name ?? 'Triage Agent'; -ctx.logger.info('Request routed', { handledBy, refundEscalations }); +export default app; ``` -The `handoff()` wrapper adds typed escalation data and a callback. Keep handoff side effects request-scoped, as shown above, or store them in Agentuity storage when another request needs to read them. After the run completes, `result.lastAgent.name` tells you which agent handled the request. Use `Agent.create()` for the triage agent to get full type inference across the handoff chain. - - -Use `Agent.create()` instead of `new Agent()` for the triage agent. This gives TypeScript full type inference across the handoff chain, including typed `inputType` schemas. - - -## Structured Context - -Use `RunContext` to pass typed data to tools at runtime. The context is available inside tool `execute` functions but is never sent to the LLM. Pair it with `outputType` to get structured JSON output. - -Define the context interface and a tool that reads from it: - -```typescript title="src/agent/structured-context/index.ts" -import { tool } from '@openai/agents'; -import type { RunContext } from '@openai/agents'; -import { z } from 'zod'; - -// Context type: available to tools, never sent to the LLM -interface UserInfo { - name: string; - uid: number; - role: string; -} - -const lookupContact = tool({ - name: 'lookup_contact', - description: 'Look up a contact by name', - parameters: z.object({ name: z.string() }), - execute: async ({ name }, ctx?: RunContext) => { - // ctx.context holds typed data from the run() call - const requester = ctx?.context.name ?? 'unknown'; // [!code highlight] - return `[Looked up by ${requester}] ${name}: alice@acme.com`; - }, -}); -``` +## Handoffs -Create the agent with `outputType` and pass context at runtime via `run()`: +Multiple agents compose with the SDK's handoff pattern. Each child agent has its own instructions and tools; the parent decides when to delegate. -```typescript title="src/agent/structured-context/index.ts" +```typescript import { Agent, run } from '@openai/agents'; -import { z } from 'zod'; -// Structured output schema: the LLM must return this exact shape -const ContactOutput = z.object({ - name: z.string(), - email: z.string(), - company: z.string(), - summary: z.string(), +const refundAgent = new Agent({ + name: 'Refund Agent', + instructions: 'Process refund requests. Confirm the order ID first.', + model: 'gpt-5.4-mini', }); -const assistant = new Agent({ - name: 'Contact Finder', - instructions: 'Look up contacts and return structured data.', - model: 'gpt-5.4', - tools: [lookupContact], - outputType: ContactOutput, // [!code highlight] +const triageAgent = new Agent({ + name: 'Triage', + instructions: 'Route the user to the right specialist.', + model: 'gpt-5.4-mini', + handoffs: [refundAgent], }); -// Pass context at runtime: tools receive it, LLM does not -const result = await run(assistant, 'Find Alice', { - context: { name: 'Demo User', uid: 42, role: 'admin' }, -}); -// result.finalOutput is typed as z.infer +const result = await run(triageAgent, 'I want a refund on order ord_123.'); ``` -- Context is passed at runtime via `run(agent, input, { context: data })` and is never sent to the LLM -- Tools receive context as the second parameter: `execute(args, ctx?: RunContext)` -- `outputType` forces the agent to return structured JSON matching the Zod schema; `result.finalOutput` is fully typed - - -`RunContext` is for runtime data that tools need (user identity, permissions, session state). Use `instructions` for LLM behavior guidance. They serve different purposes: context stays server-side, instructions go to the model. - +The SDK manages the handoff turn for you. The route still sees one round trip. -## Full Examples +## When to Reach for the OpenAI Agents SDK -Explore complete working examples for each pattern: +Pick this SDK when you want OpenAI's first-party agent loop, especially for handoffs and hosted tools. Pick the [Anthropic SDK loop](/cookbook/patterns/autonomous-research) when Anthropic's `tool_use` blocks are central. Pick raw [AI SDK](/build/agents) when you want one provider-agnostic surface. -- [Tool Calling](https://github.com/agentuity/examples/tree/main/integrations/openai/tool-calling): function tools, model config, ReAct loop -- [Agent Handoffs](https://github.com/agentuity/examples/tree/main/integrations/openai/agent-handoffs): triage routing, typed escalation, `lastAgent` tracking -- [Structured Context](https://github.com/agentuity/examples/tree/main/integrations/openai/structured-context): `RunContext` for typed tool context, `outputType` for structured JSON -- [Streaming Events](https://github.com/agentuity/examples/tree/main/integrations/openai/streaming-events): `stream: true` with real-time event timeline +Run locally with `agentuity dev` to use [AI Gateway](/services/ai-gateway) env wiring for the OpenAI SDK. For deployed apps, set provider keys directly unless you have verified the project receives the gateway env your SDK path needs. ## Next Steps -- [Creating Agents](/agents/creating-agents): Agentuity agent patterns and schemas -- [AI Gateway](/agents/ai-gateway): Managed model credentials across providers -- [Tracing](/services/observability/tracing): Observability that replaces OpenAI's built-in tracing +- [Build Agents](/build/agents): the plain-function pattern this page wraps +- [Tool Calling](/build/tool-calling): the AI SDK 6 path with provider-agnostic tool calls +- [Autonomous Research](/cookbook/patterns/autonomous-research): a hand-rolled tool loop using the Anthropic SDK diff --git a/docs/src/web/content/cookbook/integrations/tanstack-start.mdx b/docs/src/web/content/cookbook/integrations/tanstack-start.mdx deleted file mode 100644 index 673a9e52f..000000000 --- a/docs/src/web/content/cookbook/integrations/tanstack-start.mdx +++ /dev/null @@ -1,172 +0,0 @@ ---- -title: Adding Agentuity to a TanStack Start App -short_title: TanStack Start -description: Connect a TanStack Start frontend to an Agentuity backend using a Vite proxy and direct router types ---- - -Already have a [TanStack Start](https://tanstack.com/start/latest/docs/framework/react/overview) app? Keep your frontend where it is and run Agentuity in an `agentuity/` subdirectory. TanStack Start uses Vite under the hood, so the same `/api` proxy pattern used in the SDK testing app works here too. - -## Project Structure - -```text -my-tanstack-app/ -├── src/ -│ ├── components/EchoDemoClient.tsx # Client component using hc() -│ └── routes/index.tsx -├── agentuity/ -│ ├── app.ts # createApp({ router, agents }) -│ ├── src/agent/echo/agent.ts -│ └── src/api/index.ts # Exports ApiRouter -└── vite.config.ts # Proxies /api to Agentuity -``` - -## Vite Proxy Configuration - -Keep your existing TanStack Start plugins, then add a proxy entry so browser requests to `/api` go to `agentuity dev`: - -```typescript title="vite.config.ts" -import { tanstackStart } from '@tanstack/react-start/plugin/vite'; -import viteReact from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; - -export default defineConfig({ - plugins: [tanstackStart(), viteReact()], - server: { - proxy: { - '/api': { - target: 'http://localhost:3500', // [!code highlight] - changeOrigin: true, - }, - }, - }, -}); -``` - - -This pattern does not use `@agentuity/routes` or `src/generated/routes.ts`. The frontend imports `type ApiRouter` directly from the backend source file, and `hc()` infers the request shape from that type. - - -## Import the Router Type Directly - -Export a router type from the backend: - -```typescript title="agentuity/src/api/index.ts" -import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; -import echoAgent from '../agent/echo/agent'; - -const api = new Hono().post('/echo', echoAgent.validator(), async (c) => { - const data = c.req.valid('json'); - return c.json(await echoAgent.run(data)); -}); - -export type ApiRouter = typeof api; // [!code highlight] -export default api; -``` - -Use that type in your client component: - -```tsx title="src/components/EchoDemoClient.tsx" -import { hc } from 'hono/client'; // [!code highlight] -import type { InferResponseType } from 'hono/client'; -import type { ApiRouter } from '../../agentuity/src/api/index'; // [!code highlight] -import { useState } from 'react'; - -const client = hc('/api'); // [!code highlight] -const $post = client.echo.$post; -type EchoResponse = InferResponseType; - -export default function EchoDemoClient() { - const [message, setMessage] = useState('Hello from TanStack Start!'); - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const sendEcho = async () => { - setIsLoading(true); - setError(null); - - try { - const res = await $post({ json: { message } }); - if (!res.ok) { - throw new Error(`Echo request failed with ${res.status}`); - } - - setData(await res.json()); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setIsLoading(false); - } - }; - - return ( -
- setMessage(e.target.value)} - value={message} - /> - - {error &&

{error}

} - {data &&

{data.echo}

} -
- ); -} -``` - - -Use `import type { ApiRouter } ...` so the backend file is erased from the client bundle while `hc()` still gets full RPC inference. - - -## Running Locally - -Run from the project root (not from inside `agentuity/`): - -```bash -bun install -bun run dev -``` - -- Frontend: `http://localhost:3000` -- Backend: `http://localhost:3500` - -`bun run dev` starts both runtimes. You do not need a separate client route-generation step. The frontend imports the router type from source, and `agentuity dev` serves the backend. - -## Deployment - -If you keep same-origin routing in production, keep `hc('/api')` and route `/api/*` to your Agentuity service at the host or CDN layer. - -If the frontend and backend live on different origins, point `hc()` at the deployed backend instead: - -```tsx -const agentuityBaseUrl = import.meta.env.VITE_AGENTUITY_BASE_URL; -if (!agentuityBaseUrl) { - throw new Error('VITE_AGENTUITY_BASE_URL is required'); -} - -const client = hc(new URL('/api', agentuityBaseUrl).toString()); -``` - -`VITE_AGENTUITY_BASE_URL` must be a full absolute URL, including the scheme, such as `https://my-app.agentuity.run`. `new URL()` throws if you pass a bare host or a relative path. - -`/api` replaces any path suffix in `VITE_AGENTUITY_BASE_URL`. If your backend is mounted under a subpath, preserve it with a relative URL segment: - -```tsx -const baseUrl = new URL(agentuityBaseUrl); -if (!baseUrl.pathname.endsWith('/')) { - baseUrl.pathname += '/'; -} - -const client = hc(new URL('api', baseUrl).toString()); -``` - -Then enable CORS in [App Configuration](/get-started/app-configuration). - -## Next Steps - -- [RPC Client](/frontend/rpc-client): More `hc()` patterns, custom fetch, and error handling -- [Provider Setup](/frontend/provider-setup): Add auth, analytics, or WebRTC hooks -- [HTTP Routes](/routes/http): Define the backend API router you export to the frontend diff --git a/docs/src/web/content/cookbook/integrations/turborepo.mdx b/docs/src/web/content/cookbook/integrations/turborepo.mdx deleted file mode 100644 index cb23195b1..000000000 --- a/docs/src/web/content/cookbook/integrations/turborepo.mdx +++ /dev/null @@ -1,301 +0,0 @@ ---- -title: Adding Agentuity to a Turborepo Monorepo -short_title: Turborepo -description: Add Agentuity as a workspace app, share schemas across packages, and import router types directly ---- - -Already have a [Turborepo](https://turbo.build/repo) monorepo? Add Agentuity as a workspace app beside your frontend, keep shared schemas in a package both sides can import, and expose the backend router type so the web app can use `hc()` without generated route files. - -## Project Structure - -```text -my-monorepo/ -├── apps/ -│ ├── web/ -│ │ ├── src/components/TranslateDemo.tsx -│ │ └── vite.config.ts # Proxies /api to Agentuity in local dev -│ └── agentuity/ -│ ├── app.ts -│ ├── src/agent/translate/index.ts -│ ├── src/api/index.ts # Exports ApiRouter -│ └── package.json # Exports ./api for type-only imports -├── packages/ -│ └── shared/ -│ └── src/translate.ts -├── turbo.json -└── package.json -``` - -## Shared Schemas - -Define schemas once in `packages/shared`, then import them from both the agent and the frontend. The schemas use [`@agentuity/schema`](/agents/schema-libraries), but any [StandardSchema-compatible](/agents/schema-libraries) library works: - -```typescript title="packages/shared/src/translate.ts" -import { s } from '@agentuity/schema'; - -export const LANGUAGES = ['Spanish', 'French', 'German', 'Chinese'] as const; -export const MODELS = ['gpt-5.4-nano', 'gpt-5.4-mini', 'gpt-5.4'] as const; - -export type Language = (typeof LANGUAGES)[number]; -export type Model = (typeof MODELS)[number]; - -export const HistoryEntrySchema = s.object({ - model: s.string(), - sessionId: s.string(), - text: s.string(), - timestamp: s.string(), - tokens: s.number(), - toLanguage: s.string(), - translation: s.string(), -}); - -export type HistoryEntry = s.infer; - -export const TranslateInputSchema = s.object({ - model: s.enum(MODELS).optional(), - text: s.string(), - toLanguage: s.enum(LANGUAGES).optional(), -}); - -export const TranslateOutputSchema = s.object({ - history: s.array(HistoryEntrySchema), - sessionId: s.string(), - threadId: s.string(), - tokens: s.number(), - translation: s.string(), - translationCount: s.number(), -}); - -export type TranslateInput = s.infer; -export type TranslateOutput = s.infer; -``` - -## Export a Stable Router Type - -Give the frontend a stable import path for the backend router type: - -```json title="apps/agentuity/package.json" -{ - "name": "@my-monorepo/agentuity", - "exports": { - "./api": "./src/api/index.ts" - } -} -``` - -The frontend should use `import type { ApiRouter } from '@my-monorepo/agentuity/api'`. That keeps the import type-only while still pointing at the real router definition. - -## The Agent - -The agent imports schemas from the shared package. Agentuity's [AI Gateway](/agents/ai-gateway) handles model routing, so no separate provider-specific client setup is required in the frontend: - -```typescript title="apps/agentuity/src/agent/translate/index.ts" -import { createAgent } from '@agentuity/runtime'; -import OpenAI from 'openai'; -import type { HistoryEntry } from '@my-monorepo/shared'; -import { - TranslateInputSchema, - TranslateOutputSchema, -} from '@my-monorepo/shared'; - -export default createAgent('translate', { - description: 'Translates text to different languages', - schema: { - input: TranslateInputSchema, - output: TranslateOutputSchema, - }, - setup: async () => { - return { - // Create the AI Gateway client at runtime, not during route discovery - client: new OpenAI(), - }; - }, - handler: async (ctx, { text, toLanguage = 'Spanish', model = 'gpt-5.4-nano' }) => { - ctx.logger.info('Translation request', { toLanguage, model }); - - const completion = await ctx.config.client.chat.completions.create({ - model, - messages: [{ role: 'user', content: `Translate to ${toLanguage}:\n\n${text}` }], - }); - - const translation = completion.choices[0]?.message?.content ?? ''; - const tokens = completion.usage?.total_tokens ?? 0; - - // Thread state persists history across requests for the same user session - await ctx.thread.state.push('history', { text, translation, tokens, toLanguage, model }, 5); - const history = (await ctx.thread.state.get('history')) ?? []; - - return { - history, - sessionId: ctx.sessionId, - threadId: ctx.thread.id, - tokens, - translation, - translationCount: history.length, - }; - }, -}); -``` - -The agent validates input and output against the shared schemas automatically. If the handler returns a shape that doesn't match `TranslateOutputSchema`, TypeScript catches it at build time. - -## API Routes - -Routes handle HTTP concerns that don't belong in the agent itself. Export the router type from the same file: - -```typescript title="apps/agentuity/src/api/index.ts" -import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; -import { validator } from '@agentuity/runtime'; -import translate from '../agent/translate'; -import type { HistoryEntry } from '@my-monorepo/shared'; -import { TranslateOutputSchema } from '@my-monorepo/shared'; - -// Subset of output schema for state-only routes -const StateSchema = TranslateOutputSchema.pick(['history', 'threadId', 'translationCount']); - -const api = new Hono() - .post('/translate', translate.validator(), async (c) => { - const data = c.req.valid('json'); - return c.json(await translate.run(data)); - }) - // Routes use c.var.thread; agents use ctx.thread directly - .get('/translate/history', validator({ output: StateSchema }), async (c) => { - const history = (await c.var.thread.state.get('history')) ?? []; - return c.json({ history, threadId: c.var.thread.id, translationCount: history.length }); - }) - .delete('/translate/history', validator({ output: StateSchema }), async (c) => { - await c.var.thread.state.delete('history'); - return c.json({ history: [], threadId: c.var.thread.id, translationCount: 0 }); - }); - -export type ApiRouter = typeof api; // [!code highlight] -export default api; -``` - -## Proxy `/api` to the Agent App - -If your web app uses Vite, add a proxy so local browser requests to `/api` reach the Agentuity workspace app: - -```typescript title="apps/web/vite.config.ts" -import { defineConfig } from 'vite'; - -export default defineConfig({ - server: { - proxy: { - '/api': { - target: 'http://localhost:3500', // [!code highlight] - changeOrigin: true, - }, - }, - }, -}); -``` - -If your frontend uses another framework, use the equivalent rewrite or proxy there. The key is keeping the browser-facing path at `/api` so `hc('/api')` stays the same across environments. - -## Frontend Type Safety - -The `apps/web` package imports the router type from the Agentuity workspace package and shared constants from `packages/shared`: - -```tsx title="apps/web/src/components/TranslateDemo.tsx" -import { hc } from 'hono/client'; // [!code highlight] -import type { ApiRouter } from '@my-monorepo/agentuity/api'; // [!code highlight] -import type { Language, Model } from '@my-monorepo/shared'; -import { useState } from 'react'; -import { LANGUAGES, MODELS } from '@my-monorepo/shared'; - -const client = hc('/api'); // [!code highlight] - -export function TranslateDemo() { - const [model] = useState(MODELS[0]); - const [toLanguage] = useState(LANGUAGES[0]); - const [data, setData] = useState<{ translation: string } | null>(null); - const [isLoading, setIsLoading] = useState(false); - - const onTranslate = async () => { - setIsLoading(true); - try { - const res = await client.translate.$post({ // [!code highlight] - json: { text: 'Hello world', toLanguage, model }, - }); - setData(await res.json()); - } finally { - setIsLoading(false); - } - }; - - return ( -
- - {data?.translation &&

{data.translation}

} -
- ); -} -``` - - -Use `import type { ApiRouter } ...` so the router import is erased from the frontend bundle while `hc()` still gets full RPC inference. - - -## Turborepo Task Graph - -There is no `build:routes` step in v2. `agentuity build` discovers routes from `createApp({ router })` and generates runtime metadata for the backend, but it does not emit a client route registry. Keep the Turbo pipeline focused on the normal workspace tasks: - -```json title="turbo.json" -{ - "$schema": "https://turborepo.dev/schema.json", - "tasks": { - "dev": { - "cache": false, - "persistent": true - }, - "build": { - "dependsOn": ["^build"], - "outputs": ["dist/**", ".agentuity/**"] - }, - "typecheck": { - "dependsOn": ["^typecheck"] - } - } -} -``` - -Use whatever workspace `dev` scripts make sense for your monorepo. The important part is that the frontend can resolve the Agentuity workspace package for types and can reach the Agentuity dev server through `/api`. - -## Running Locally - -From the monorepo root: - -```bash -bun install -bun run dev -``` - -- Frontend: `http://localhost:3000` -- Backend: `http://localhost:3500` - -There is no separate client route-generation step in v2. The web app imports the router type from the workspace source, and `agentuity dev` serves the backend. - -## Deployment - -The frontend and backend deploy independently: - -```bash -# Deploy the agent -cd apps/agentuity && bun run deploy - -# Build the frontend for your hosting provider -cd apps/web && bun run build -``` - -When deployed, either keep routing `/api/*` to the Agentuity backend at your edge or point `hc()` at the deployed backend URL and enable CORS in [App Configuration](/get-started/app-configuration). See [Deployment Scenarios](/frontend/deployment-scenarios) for the host-level options. - -## Next Steps - -- [Schema Libraries](/agents/schema-libraries): Use Zod, Valibot, ArkType, or `@agentuity/schema` for shared schemas -- [RPC Client](/frontend/rpc-client): More `hc()` patterns, custom fetch, and error handling -- [HTTP Routes](/routes/http): Define and validate backend API routes -- [Deployment Scenarios](/frontend/deployment-scenarios): Route frontend traffic to a deployed agent diff --git a/docs/src/web/content/cookbook/patterns/autonomous-research.mdx b/docs/src/web/content/cookbook/patterns/autonomous-research.mdx index 55b279be7..c9f8537e4 100644 --- a/docs/src/web/content/cookbook/patterns/autonomous-research.mdx +++ b/docs/src/web/content/cookbook/patterns/autonomous-research.mdx @@ -1,24 +1,24 @@ --- -title: Autonomous Research Agent +title: Autonomous Research with Anthropic Tool Use short_title: Autonomous Research -description: Build a recursive research loop using the Anthropic SDK with native tool calling +description: Build a recursive research loop using the Anthropic SDK Messages API and native tool_use blocks --- -When you want full control over the agent loop, you can skip the AI SDK abstraction and use a provider SDK directly. This pattern uses the [Anthropic SDK](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview) with native tool calling to build a research agent that investigates topics through Wikipedia. +The AI SDK is the right default for tool calling. Reach for the raw Anthropic SDK when you want full control over the message loop, native `tool_use` blocks, or Anthropic-specific features. This pattern uses Wikipedia as a free, no-auth research source. -## The Pattern +```bash +bun add hono @anthropic-ai/sdk @agentuity/schema@alpha +``` + +## Define Tools and Schemas -Define tools as plain objects, run a for-loop, and check `stop_reason` after each iteration. The model decides which tool to call, you execute it, and feed the result back. The loop ends when the model calls a "finish" tool or runs out of steps. +Anthropic tools use JSON Schema for `input_schema`. Validate the model's tool input with [`@agentuity/schema`](/reference/sdk-reference/schema) before calling the implementation. -```typescript title="src/agent/researcher/agent.ts" +```typescript title="src/lib/research-agent.ts" import Anthropic from '@anthropic-ai/sdk'; -import { createAgent } from '@agentuity/runtime'; import { s } from '@agentuity/schema'; -const client = new Anthropic(); -const MAX_STEPS = 8; - -const SearchWikipediaInput = s.object({ query: s.string() }); +const SearchInput = s.object({ query: s.string() }); const GetArticleInput = s.object({ title: s.string() }); const FinishResearchInput = s.object({ summary: s.string(), @@ -29,12 +29,7 @@ const WikipediaSearchResponse = s.object({ query: s.optional( s.object({ search: s.optional( - s.array( - s.object({ - title: s.string(), - snippet: s.string(), - }) - ) + s.array(s.object({ title: s.string(), snippet: s.string() })) ), }) ), @@ -44,32 +39,19 @@ const WikipediaExtractResponse = s.object({ query: s.optional( s.object({ pages: s.optional( - s.record( - s.string(), - s.object({ - extract: s.optional(s.string()), - }) - ) + s.record(s.string(), s.object({ extract: s.optional(s.string()) })) ), }) ), }); -``` - -## Defining Tools - -Anthropic tools use JSON Schema for input validation. Each tool has a `name`, `description`, and `input_schema`. -```typescript title="src/agent/researcher/agent.ts" const tools: Anthropic.Tool[] = [ { name: 'search_wikipedia', description: 'Search Wikipedia for articles matching a query. Returns titles and snippets.', input_schema: { - type: 'object' as const, - properties: { - query: { type: 'string', description: 'The search query' }, - }, + type: 'object', + properties: { query: { type: 'string', description: 'The search query' } }, required: ['query'], }, }, @@ -77,21 +59,19 @@ const tools: Anthropic.Tool[] = [ name: 'get_article', description: 'Get the introductory text of a Wikipedia article by title.', input_schema: { - type: 'object' as const, - properties: { - title: { type: 'string', description: 'The exact Wikipedia article title' }, - }, + type: 'object', + properties: { title: { type: 'string', description: 'Exact article title' } }, required: ['title'], }, }, { name: 'finish_research', - description: 'Call this when you have gathered enough information to write a summary.', + description: 'Call when you have enough information to write a 2-3 paragraph summary.', input_schema: { - type: 'object' as const, + type: 'object', properties: { - summary: { type: 'string', description: 'A 2-3 paragraph synthesis of your research' }, - sourcesUsed: { type: 'number', description: 'Number of distinct articles you read' }, + summary: { type: 'string' }, + sourcesUsed: { type: 'number' }, }, required: ['summary', 'sourcesUsed'], }, @@ -99,83 +79,47 @@ const tools: Anthropic.Tool[] = [ ]; ``` -The `finish_research` tool doubles as the structured output mechanism. When the model has enough information, it calls this tool with the final summary and source count. No separate summarization step needed. - -## The Agent Loop - -The core pattern is a for-loop that sends messages, checks for tool calls, executes them, and appends results. This gives you direct visibility into every step. - -```typescript title="src/agent/researcher/agent.ts" -const agent = createAgent('researcher', { - description: 'Researches a topic using Wikipedia and returns a structured summary', - schema: { - input: s.object({ topic: s.string() }), - output: s.object({ summary: s.string(), sourcesUsed: s.number() }), - }, - handler: async (ctx, input) => { - ctx.logger.info('Starting research on: %s', input.topic); - - const messages: Anthropic.MessageParam[] = [ - { role: 'user', content: `Research this topic thoroughly: ${input.topic}` }, - ]; - - for (let step = 0; step < MAX_STEPS; step++) { - const response = await client.messages.create({ - model: 'claude-sonnet-4-6', - max_tokens: 4096, - system: SYSTEM_PROMPT, - tools, - messages, - }); - - // No tool calls means the model is done - if (response.stop_reason !== 'tool_use') break; // [!code highlight] - - // Add assistant response (contains tool_use blocks) - messages.push({ role: 'assistant', content: response.content }); +`finish_research` doubles as the structured output mechanism. The loop ends when the model calls it, and the call's input becomes the final result. - // Execute each tool call - const toolResults: Anthropic.ToolResultBlockParam[] = []; +## Implement the Tools - for (const block of response.content) { - if (block.type !== 'tool_use') continue; +Wikipedia exposes search and extract endpoints over plain `fetch`. - ctx.logger.info('Step %d: %s(%s)', step, block.name, JSON.stringify(block.input)); - - // finish_research carries the final output - if (block.name === 'finish_research') { // [!code highlight] - const result = FinishResearchInput.parse(block.input); - ctx.logger.info('Research complete: %d sources used', result.sourcesUsed); - return result; // [!code highlight] - } - - const result = await executeTool(block.name, block.input); - toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: result }); - } +```typescript title="src/lib/research-agent.ts" +async function searchWikipedia(query: string): Promise { + const url = `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&format=json&origin=*&srlimit=3`; + const res = await fetch(url); + if (!res.ok) return `Search failed: HTTP ${res.status}`; - // Feed tool results back to continue the loop - messages.push({ role: 'user', content: toolResults }); // [!code highlight] - } + const parsed = WikipediaSearchResponse.safeParse(await res.json()); + if (!parsed.success) return 'No results found.'; - // Fallback if the model never called finish_research - return { summary: 'Research could not be completed. Try a more specific topic.', sourcesUsed: 0 }; - }, -}); + const results = parsed.data.query?.search ?? []; + if (results.length === 0) return 'No results found.'; -export default agent; -``` + return JSON.stringify( + results.map((r) => ({ title: r.title, snippet: r.snippet.replace(/<[^>]*>/g, '') })) + ); +} -The message history grows with each iteration: assistant messages contain `tool_use` blocks, and user messages contain `tool_result` blocks. This is the format Anthropic expects for multi-turn tool conversations. +async function getArticle(title: string): Promise { + const url = `https://en.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=extracts&exintro=true&explaintext=true&format=json&origin=*`; + const res = await fetch(url); + if (!res.ok) return `Failed to fetch article: HTTP ${res.status}`; -## Tool Execution + const parsed = WikipediaExtractResponse.safeParse(await res.json()); + if (!parsed.success) return 'No content found.'; -Tool implementations are plain async functions. The Wikipedia API provides search and article content without authentication. + const pages = parsed.data.query?.pages; + if (!pages) return 'No content found.'; + const [page] = Object.values(pages); + return page?.extract ?? 'No content found.'; +} -```typescript title="src/agent/researcher/agent.ts" async function executeTool(name: string, input: unknown): Promise { switch (name) { case 'search_wikipedia': { - const { query } = SearchWikipediaInput.parse(input); + const { query } = SearchInput.parse(input); return searchWikipedia(query); } case 'get_article': { @@ -186,72 +130,114 @@ async function executeTool(name: string, input: unknown): Promise { return `Unknown tool: ${name}`; } } +``` -async function searchWikipedia(query: string): Promise { - const url = `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&format=json&origin=*&srlimit=3`; - const res = await fetch(url); - if (!res.ok) return `Wikipedia search failed: HTTP ${res.status}`; +## Run the Loop - const data = WikipediaSearchResponse.safeParse(await res.json()); - if (!data.success) return 'No results found.'; +The loop sends messages, checks `stop_reason`, executes any `tool_use` blocks, and feeds the results back. It exits when the model calls `finish_research` or hits the step cap. - const results = data.data.query?.search ?? []; - if (results.length === 0) return 'No results found.'; +```typescript title="src/lib/research-agent.ts" +const SYSTEM_PROMPT = `You are a research assistant that investigates topics using Wikipedia. + +Follow this loop: +1. Plan: decide what to search for next +2. Search: call search_wikipedia +3. Read: call get_article on the most relevant result +4. Finish: when you have enough (usually 2-4 sources), call finish_research`; + +const MAX_STEPS = 8; +const client = new Anthropic(); - return JSON.stringify(results.map((r) => ({ - title: r.title, - snippet: r.snippet.replace(/<[^>]*>/g, ''), - }))); +interface ResearchResult { + readonly summary: string; + readonly sourcesUsed: number; } -async function getArticle(title: string): Promise { - const url = `https://en.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=extracts&exintro=true&explaintext=true&format=json&origin=*`; - const res = await fetch(url); - if (!res.ok) return `Failed to fetch article: HTTP ${res.status}`; +export async function research(topic: string): Promise { + const messages: Anthropic.MessageParam[] = [ + { role: 'user', content: `Research this topic thoroughly: ${topic}` }, + ]; - const data = WikipediaExtractResponse.safeParse(await res.json()); - if (!data.success) return 'No content found.'; + for (let step = 0; step < MAX_STEPS; step++) { + const response = await client.messages.create({ + model: 'claude-sonnet-4-6', + max_tokens: 4096, + system: SYSTEM_PROMPT, + tools, + messages, + }); - const pages = data.data.query?.pages; - if (!pages) return 'No content found.'; - const [page] = Object.values(pages); - return page?.extract ?? 'No content found.'; + if (response.stop_reason !== 'tool_use') break; + + messages.push({ role: 'assistant', content: response.content }); + + const toolResults: Anthropic.ToolResultBlockParam[] = []; + let finalResult: ResearchResult | undefined; + + for (const block of response.content) { + if (block.type !== 'tool_use') continue; + + if (block.name === 'finish_research') { + finalResult = FinishResearchInput.parse(block.input); + continue; + } + + const result = await executeTool(block.name, block.input); + toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: result }); + } + + if (finalResult) { + return finalResult; + } + + messages.push({ role: 'user', content: toolResults }); + } + + return { summary: 'Research could not be completed. Try a more specific topic.', sourcesUsed: 0 }; } ``` -## The System Prompt +A few details that matter: -The system prompt establishes the plan-act-observe-repeat cycle and tells the model when to finish. +- assistant turns carry `tool_use` blocks; the next user turn carries matching `tool_result` blocks. Anthropic enforces that pairing. +- `finish_research` has a JSON Schema, so `block.input` is the validated final summary. No second `generateObject` call needed. +- the loop breaks when the model returns `stop_reason !== 'tool_use'`, which means it stopped on `end_turn` or hit `max_tokens`. -```typescript title="src/agent/researcher/agent.ts" -const SYSTEM_PROMPT = `You are a research assistant that investigates topics using Wikipedia. +## Wire the Route -Follow this loop: -1. **Plan** -- decide what to search for next -2. **Search** -- use search_wikipedia to find relevant articles -3. **Read** -- use get_article to read promising article intros -4. **Finish** -- once you have enough information (usually 2-4 sources), call finish_research +The route validates the input topic and calls the agent function. The function is plain async code, so it works the same way from a queue consumer or a script. -When calling finish_research, write a clear 2-3 paragraph summary that synthesizes what you learned. Include the total number of distinct articles you read.`; -``` +```typescript title="src/index.ts" +import { Hono } from 'hono'; +import { s } from '@agentuity/schema'; +import { research } from './lib/research-agent'; -## When to Use Direct Provider SDKs +const requestSchema = s.object({ topic: s.string() }); -The [AI SDK](/agents/ai-sdk-integration) provides a unified interface across providers, which is the right default for most agents. Use a provider SDK directly when you need: +const app = new Hono(); + +app.post('/api/research', async (c) => { + const body: unknown = await c.req.json(); + const { topic } = requestSchema.parse(body); + const result = await research(topic); + return c.json(result); +}); + +export default app; +``` -- Full access to provider-specific features (streaming events, content block types, caching) -- Precise control over the message format and conversation structure -- Minimal abstraction for debugging or learning how tool calling works +## When to Reach for the Anthropic SDK -Both approaches work with `createAgent()` and the [AI Gateway](/agents/ai-gateway). When you run with an Agentuity SDK key through `agentuity dev` or a deployed Agentuity app, provider SDK calls route through the AI Gateway. If you set provider API keys directly, those SDKs talk to the provider. +| Pick this when | Otherwise prefer | +|---|---| +| You want native `tool_use` and `tool_result` block visibility | [AI SDK 6](https://ai-sdk.dev/docs/foundations/tools) | +| You need Anthropic-specific features like extended thinking, caching, or batch | AI SDK | +| You want full control over the message conversation shape | AI SDK with `Output.object` | - -See the [Research Agent](https://github.com/agentuity/examples/tree/main/features/research-agent) example for the complete implementation with a React frontend, SSE streaming, and topic input UI. - +Run locally with `agentuity dev` to use [AI Gateway](/services/ai-gateway) env wiring for the Anthropic SDK. For deployed apps, set provider keys directly unless you have verified the project receives the gateway env your SDK path needs. ## Next Steps -- [AI SDK Integration](/agents/ai-sdk-integration): The unified approach to tool calling across providers -- [AI Gateway](/agents/ai-gateway): How LLM calls are routed, observed, and billed -- [Understanding Agents](/cookbook/tutorials/understanding-agents): The agent loop concept explained -- [Creating Agents](/agents/creating-agents): Agent schemas, handlers, and lifecycle +- [Build Agents](/build/agents): the plain-function pattern this page uses +- [Tool Calling](/build/tool-calling): the AI SDK 6 path with provider-agnostic tool calls +- [LLM as a Judge](/cookbook/patterns/llm-as-a-judge): score the research output before returning it diff --git a/docs/src/web/content/cookbook/patterns/background-tasks.mdx b/docs/src/web/content/cookbook/patterns/background-tasks.mdx index 4cfa7b1f1..32e1f2207 100644 --- a/docs/src/web/content/cookbook/patterns/background-tasks.mdx +++ b/docs/src/web/content/cookbook/patterns/background-tasks.mdx @@ -1,167 +1,101 @@ --- -title: Background Tasks -description: Use waitUntil to return quickly while background work continues +title: Fire-and-Forget Background Work +short_title: Background Tasks +description: Return a fast response while side effects continue in the background, plus when to upgrade to a durable queue --- -Use `waitUntil` for work the caller does not need to wait on, such as analytics, notifications, or cleanup. The response returns immediately while the runtime tracks the work until it finishes. - -## The Pattern - -`waitUntil` accepts a promise or callback. The callback starts right away, runs independently of the response, and multiple callbacks can run at the same time. - -This example uses `@agentuity/schema` for lightweight input and output validation: - -```typescript title="src/agent/order-processor/agent.ts" -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; - -const agent = createAgent('OrderProcessor', { - schema: { - input: s.object({ - orderId: s.string(), - userId: s.string(), - }), - output: s.object({ - status: s.literal('confirmed'), - orderId: s.string(), - }), - }, - handler: async (ctx, input) => { - const { orderId, userId } = input; - - // Process the order synchronously - const order = await processOrder(orderId); - - // Background: send confirmation email - ctx.waitUntil(async () => { // [!code highlight] - await sendConfirmationEmail(userId, order); - ctx.logger.info('Confirmation email sent', { orderId }); - }); - - // Background: update analytics - ctx.waitUntil(async () => { // [!code highlight] - await trackPurchase(userId, order); - }); - - // Background: notify warehouse - ctx.waitUntil(async () => { // [!code highlight] - await notifyWarehouse(order); - }); - - // Response returns immediately, background tasks continue - return { - status: 'confirmed', - orderId, - }; - }, -}); +Some side effects do not need to complete before the response goes out: analytics, audit log writes, notification fan-out. Two paths cover the common cases. -export default agent; -``` +| Need | Use | +|---|---| +| ephemeral side effect, OK if dropped on restart | in-process promise | +| durable handoff with retries, status, and observability | [Queues](/services/queues) and the [Background Work](/build/background-work) pattern | + +## In-Process Promise + +For best-effort work, kick off the promise and return. Wrap it in `try/catch` so an uncaught rejection cannot crash the process. + +```typescript title="src/index.ts" +import { Hono } from 'hono'; +import { logger } from '@agentuity/telemetry'; + +const app = new Hono(); -## With Durable Streams - -Create a stream for the client to poll, then populate it in the background: - -```typescript title="src/agent/async-generator/agent.ts" -import { createAgent } from '@agentuity/runtime'; -import { streamText } from 'ai'; -import { openai } from '@ai-sdk/openai'; -import { s } from '@agentuity/schema'; - -const agent = createAgent('AsyncGenerator', { - schema: { - input: s.object({ prompt: s.string() }), - output: s.object({ - streamId: s.string(), - streamUrl: s.string(), - }), - }, - handler: async (ctx, input) => { - // Create a durable stream the client can read from - const stream = await ctx.stream.create('generation', { // [!code highlight] - contentType: 'text/plain', - metadata: { sessionId: ctx.sessionId }, - }); - - // Generate content in the background - ctx.waitUntil(async () => { // [!code highlight] - try { - const { textStream } = streamText({ - model: openai('gpt-5-mini'), - prompt: input.prompt, - }); - - for await (const chunk of textStream) { - await stream.write(chunk); - } - } finally { - await stream.close(); // [!code highlight] - } - }); - - // Return stream URL immediately - return { - streamId: stream.id, - streamUrl: stream.url, - }; - }, +app.post('/api/orders', async (c) => { + const body = await c.req.json<{ orderId: string; userId: string }>(); + + // do the durable work first + await confirmOrder(body.orderId); + + // ephemeral side effects: best-effort, do not block the response + void runInBackground(() => trackPurchase(body.userId, body.orderId)); + void runInBackground(() => notifyWarehouse(body.orderId)); + + return c.json({ status: 'confirmed', orderId: body.orderId }); }); -export default agent; +async function runInBackground(fn: () => Promise): Promise { + try { + await fn(); + } catch (error) { + logger.warn('background task failed', { error }); + } +} + +declare function confirmOrder(orderId: string): Promise; +declare function trackPurchase(userId: string, orderId: string): Promise; +declare function notifyWarehouse(orderId: string): Promise; + +export default app; ``` -## Progress Reporting - -Write progress updates to a stream as background work proceeds: - -```typescript title="src/agent/batch-processor/agent.ts" -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; - -const agent = createAgent('BatchProcessor', { - schema: { - input: s.object({ items: s.array(s.string()) }), - output: s.object({ progressUrl: s.string() }), - }, - handler: async (ctx, input) => { - const progress = await ctx.stream.create('progress', { - contentType: 'application/x-ndjson', - }); - - ctx.waitUntil(async () => { - try { - for (const [index, item] of input.items.entries()) { - await processItem(item); - - await progress.write(JSON.stringify({ - completed: index + 1, - total: input.items.length, - percent: Math.round(((index + 1) / input.items.length) * 100), - }) + '\n'); - } - - await progress.write(JSON.stringify({ done: true }) + '\n'); - } finally { - await progress.close(); - } - }); - - return { progressUrl: progress.url }; - }, +`logger` from `@agentuity/telemetry` writes through the Agentuity logger pipeline. Use `c.var.logger` inside Hono handlers when [`@agentuity/hono`](/build/agents) middleware is mounted. + + +A redeploy, container shutdown, or crash drops in-flight promises. Use this pattern only for work the app can lose without consequence. Move durable work to a queue. + + +## Durable Queue Handoff + +When the work must survive restarts, has retry semantics, or needs status reporting, hand it off to a queue. The request route publishes; a worker route runs the slow part. + +```typescript +import { Hono } from 'hono'; +import { QueueClient } from '@agentuity/queue'; + +const queue = new QueueClient(); +const app = new Hono(); + +app.post('/api/orders', async (c) => { + const body = await c.req.json<{ orderId: string; userId: string }>(); + + await confirmOrder(body.orderId); + + await queue.publish( + 'order-side-effects', + { orderId: body.orderId, userId: body.userId }, + { idempotencyKey: body.orderId } + ); + + return c.json({ status: 'confirmed', orderId: body.orderId }); }); -export default agent; +declare function confirmOrder(orderId: string): Promise; + +export default app; ``` -## Key Points +The full request, status, worker, and stream wiring lives in [Background Work](/build/background-work). + +## Notes -- **Non-blocking**: Response returns immediately while tracked work continues -- **Concurrent**: Multiple `waitUntil` callbacks run at the same time -- **Error isolation**: Background task failures don't affect the response -- **Always close streams**: Use `finally` blocks to ensure cleanup +- run only ephemeral side effects in process, not durable work +- always wrap the promise so a rejection does not crash the runtime +- prefer the queue path for anything that must reach a definite state +- record an idempotency key in the queue payload when retries should converge -## See Also +## Next Steps -- [Durable Streams](/services/storage/durable-streams) for stream creation and management -- [Webhook Handler](/cookbook/patterns/webhook-handler) for another `waitUntil` example +- [Background Work](/build/background-work): the durable request/status/worker/stream pattern +- [Queues](/services/queues): publish API, partitioning, idempotency, and HTTP destinations +- [Webhook Handler](/cookbook/patterns/webhook-handler): another queue handoff pattern, scoped to incoming webhooks diff --git a/docs/src/web/content/cookbook/patterns/chat-with-history.mdx b/docs/src/web/content/cookbook/patterns/chat-with-history.mdx index e1dd4ebd0..a85596165 100644 --- a/docs/src/web/content/cookbook/patterns/chat-with-history.mdx +++ b/docs/src/web/content/cookbook/patterns/chat-with-history.mdx @@ -1,207 +1,164 @@ --- title: Chat with Conversation History short_title: Chat with History -description: Build a chat agent that remembers previous messages using thread state +description: Store chat history with key-value storage from a framework route --- -Use thread state to keep conversation history across multiple requests. Thread state expires after 1 hour of inactivity, which fits short-lived chat sessions. +Use key-value storage when a chat route needs to remember recent turns. In v3, keep the route in your framework and store the conversation under an explicit `conversationId`. -## The Pattern - -Store each turn in `ctx.thread.state`, then pass the saved messages back to the model on the next request. Each browser session gets its own thread, so conversations stay separate. +```bash +bun add hono openai @agentuity/keyvalue@alpha @agentuity/schema@alpha +``` -```typescript title="src/agent/chat/agent.ts" -import { createAgent } from '@agentuity/runtime'; -import { streamText } from 'ai'; -import { anthropic } from '@ai-sdk/anthropic'; +```typescript title="src/index.ts" +import { Hono } from 'hono'; +import OpenAI from 'openai'; +import { KeyValueClient } from '@agentuity/keyvalue'; import { s } from '@agentuity/schema'; -interface Message { - role: 'user' | 'assistant'; - content: string; -} - -const agent = createAgent('Chat Agent', { - description: 'Conversational agent with memory', - schema: { - input: s.object({ - message: s.string(), - }), - stream: true, - }, - handler: async (ctx, input) => { - // Load this thread's conversation history - const messages = (await ctx.thread.state.get('messages')) || []; // [!code highlight] - - // Include the new user turn before calling the model - messages.push({ role: 'user', content: input.message }); - - const { textStream, text } = streamText({ - model: anthropic('claude-sonnet-4-6'), - system: 'You are a helpful assistant. Be concise but friendly.', - messages, - }); - - // Save the assistant turn after streaming completes - ctx.waitUntil(async () => { // [!code highlight] - const fullResponse = await text; - messages.push({ role: 'assistant', content: fullResponse }); - await ctx.thread.state.set('messages', messages); // [!code highlight] - ctx.logger.info('Conversation updated', { - messageCount: messages.length, - threadId: ctx.thread.id, - }); - }); - - return textStream; - }, +const chatRequestSchema = s.object({ + conversationId: s.string(), + message: s.string(), }); -export default agent; -``` +const messageSchema = s.object({ + role: s.enum(['user', 'assistant']), + content: s.string(), +}); -## Route Example +const historySchema = s.array(messageSchema); -```typescript title="src/api/chat/route.ts" -import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; -import chatAgent from '@agent/chat/agent'; +type ChatMessage = s.infer; -const router = new Hono(); +const HISTORY_NAMESPACE = 'chat-history'; +const HISTORY_LIMIT = 20; +const HISTORY_TTL_SECONDS = 60 * 60 * 24 * 30; -router.post('/', chatAgent.validator(), async (c) => { - const { message } = c.req.valid('json'); - return c.body(await chatAgent.run({ message })); // [!code highlight] -}); +const app = new Hono(); +const kv = new KeyValueClient(); +const openai = new OpenAI(); -router.delete('/', async (c) => { - await c.var.thread.destroy(); // [!code highlight] - return c.json({ reset: true }); -}); +async function readHistory(conversationId: string): Promise { + const stored = await kv.get(HISTORY_NAMESPACE, conversationId); + return stored.exists ? historySchema.parse(stored.data) : []; +} -export default router; -``` +async function saveHistory( + conversationId: string, + messages: readonly ChatMessage[] +): Promise { + const next = messages.slice(-HISTORY_LIMIT); + await kv.set(HISTORY_NAMESPACE, conversationId, next, { + ttl: HISTORY_TTL_SECONDS, + }); -## Frontend + return next; +} -A simple chat interface that displays streaming responses: +app.post('/api/chat', async (c) => { + const body: unknown = await c.req.json(); + const input = chatRequestSchema.parse(body); -```tsx title="src/web/App.tsx" -import { useState, useRef, useEffect } from 'react'; + const history = await readHistory(input.conversationId); + const userMessage: ChatMessage = { + role: 'user', + content: input.message, + }; -interface Message { - role: 'user' | 'assistant'; - content: string; -} + const messages = [...history, userMessage]; -export function App() { - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(''); - const [isStreaming, setIsStreaming] = useState(false); - const messagesEndRef = useRef(null); + const completion = await openai.chat.completions.create({ + model: 'gpt-5.5', + messages: [ + { role: 'system', content: 'You are a concise product support assistant.' }, + ...messages, + ], + }); - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); + const assistantMessage: ChatMessage = { + role: 'assistant', + content: completion.choices[0]?.message?.content ?? '', + }; - const sendMessage = async () => { - if (!input.trim() || isStreaming) return; + const nextHistory = await saveHistory(input.conversationId, [ + ...messages, + assistantMessage, + ]); + + return c.json({ + conversationId: input.conversationId, + message: assistantMessage, + messageCount: nextHistory.length, + model: completion.model, + tokens: completion.usage?.total_tokens ?? 0, + }); +}); - const userMessage = input.trim(); - setInput(''); - setMessages((prev) => [...prev, { role: 'user', content: userMessage }]); - setIsStreaming(true); +app.get('/api/chat/:conversationId', async (c) => { + const conversationId = c.req.param('conversationId'); + const messages = await readHistory(conversationId); - // Reserve a bubble for the streaming assistant response - setMessages((prev) => [...prev, { role: 'assistant', content: '' }]); + return c.json({ conversationId, messages }); +}); - const response = await fetch('/api/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message: userMessage }), - }); +app.delete('/api/chat/:conversationId', async (c) => { + const conversationId = c.req.param('conversationId'); + await kv.delete(HISTORY_NAMESPACE, conversationId); - const reader = response.body?.getReader(); - const decoder = new TextDecoder(); + return c.json({ conversationId, messages: [] }); +}); - while (reader) { - const { done, value } = await reader.read(); - if (done) break; +export default app; +``` - const chunk = decoder.decode(value); - setMessages((prev) => { - const updated = [...prev]; - const lastMessage = updated.at(-1); +`KeyValueClient` reads `AGENTUITY_SDK_KEY` from the environment. Run this through `agentuity dev` or a linked Agentuity project when you want managed key-value storage and AI Gateway routing. - if (lastMessage) { - lastMessage.content += chunk; - } +## Call the Route - return updated; - }); - } +Generate a conversation ID in your app, cookie, or authenticated user session. Pass that ID on each request. - setIsStreaming(false); - }; +```typescript +const conversationId = crypto.randomUUID(); + +const response = await fetch('/api/chat', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + conversationId, + message: 'What changed in the v3 Agentuity starter?', + }), +}); - return ( -
-
- {messages.map((msg, i) => ( -
- {msg.content || '...'} -
- ))} -
-
- -
- setInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && sendMessage()} - placeholder="Type a message..." - disabled={isStreaming} - style={{ flex: 1, padding: '0.75rem' }} - /> - -
-
- ); +if (!response.ok) { + throw new Error(`Chat failed with ${response.status}`); } -``` -The frontend reads the streaming response chunk by chunk and updates the UI as text arrives. +const result = await response.json(); +``` -## Key Points +The response includes the assistant message, model name, token count when the provider returns usage, and the stored message count. -- **Thread state** (`ctx.thread.state`) expires after 1 hour of inactivity -- **Async API**: All thread state methods are async (`await ctx.thread.state.get()`) -- **Messages array** stores the full conversation history -- **`waitUntil`** saves the response after streaming completes -- **Thread ID** (`ctx.thread.id`) identifies the conversation +## Reset a Conversation - -For append-only patterns like chat history, use `push()` with `maxRecords` for automatic sliding window behavior: +Delete the key when the user starts over: ```typescript -await ctx.thread.state.push('messages', newMessage, 100); // Keep last 100 +await fetch(`/api/chat/${conversationId}`, { method: 'DELETE' }); ``` + +## Notes + +- use one namespace for chat history and make the key explicit +- store a rolling window or summary instead of unlimited messages +- keep provider token usage in the response when the UI should show model details +- use key-value storage or a database for new app state, not legacy runtime thread state + + +Older v2 apps that use `ctx.thread.state` can keep that model while you plan a migration. New v3 examples should use framework-owned state boundaries: cookies, database rows, key-value records, or authenticated user IDs. -## See Also +## Next Steps -- [State Management](/agents/state-management) for all state scopes -- [Streaming Responses](/agents/streaming-responses) for streaming patterns +- [Chat and Streaming](/build/chat-and-streaming): stream assistant text while storing the final transcript +- [Key-Value Storage](/services/storage/key-value): configure TTLs, search keys, and manage namespaces +- [Migrating Runtime Apps to Frameworks](/migration/runtime-to-frameworks): move legacy runtime state into v3 app-owned state diff --git a/docs/src/web/content/cookbook/patterns/creating-coder-sessions-with-sdk.mdx b/docs/src/web/content/cookbook/patterns/creating-coder-sessions-with-sdk.mdx index f7c85dd0c..d5208819d 100644 --- a/docs/src/web/content/cookbook/patterns/creating-coder-sessions-with-sdk.mdx +++ b/docs/src/web/content/cookbook/patterns/creating-coder-sessions-with-sdk.mdx @@ -4,7 +4,7 @@ short_title: Manage Sessions description: Create a Coder session, read its state, and manage its lifecycle with CoderClient --- -Drive Coder from your own code with `CoderClient`. Coder sessions run on a shared Hub, the real-time broadcast bus that apps, agents, and humans all subscribe to. This page covers the core client-side flow: create a session, read it back, and manage its lifecycle over time. +Drive Coder from your own code with `CoderClient`. Coder sessions run on a shared Hub, the real-time broadcast bus that apps, agents, and humans all subscribe to. The flow below creates a session, reads it back, and manages its lifecycle over time. This flow works without deploying your app. If you already created an Agentuity project, its local `.env` usually includes `AGENTUITY_SDK_KEY`, which is enough for `CoderClient` discovery mode. diff --git a/docs/src/web/content/cookbook/patterns/cron-with-storage.mdx b/docs/src/web/content/cookbook/patterns/cron-with-storage.mdx index 3b388d139..329d67cb6 100644 --- a/docs/src/web/content/cookbook/patterns/cron-with-storage.mdx +++ b/docs/src/web/content/cookbook/patterns/cron-with-storage.mdx @@ -1,22 +1,24 @@ --- -title: Cron with Storage -description: Cache scheduled task results in KV for later retrieval +title: Cron with Cached Storage +short_title: Cron with Storage +description: Refresh data on a schedule, cache it in key-value storage, and serve fast reads from a normal route --- -Refresh data on a schedule, cache it in KV, then serve the last result from a regular GET route. This keeps reads fast and avoids refetching external APIs for every request. +Read-heavy routes can serve a cached payload while a scheduled task refreshes it in the background. The schedule fires a worker route, the worker writes to key-value storage, and a regular GET route returns whatever is current. -## The Pattern +```bash +bun add hono @agentuity/schedule@alpha @agentuity/keyvalue@alpha @agentuity/schema@alpha +``` -Fetch external data on a schedule and store it in KV. A separate GET endpoint retrieves the cached results. +## Define the Worker and Reader Routes -```typescript title="src/api/hn/route.ts" +The worker route is what the schedule calls. It fetches external data, validates it, and writes a single key. The reader route serves whatever is in key-value storage. + +```typescript title="src/index.ts" import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; -import { cron } from '@agentuity/runtime'; // [!code highlight] +import { KeyValueClient } from '@agentuity/keyvalue'; import { s } from '@agentuity/schema'; -const router = new Hono(); - const TopStoryIdsSchema = s.array(s.number()); const StorySchema = s.object({ @@ -27,8 +29,6 @@ const StorySchema = s.object({ url: s.optional(s.string()), }); -type Story = s.infer; - const CachedStoriesSchema = s.object({ stories: s.array(StorySchema), fetchedAt: s.string(), @@ -36,10 +36,14 @@ const CachedStoriesSchema = s.object({ type CachedStories = s.infer; -// Runs every hour and caches top HN stories -router.post('/digest', cron('0 * * * *', { auth: true }, async (c) => { // [!code highlight] - c.var.logger.info('Fetching HN stories'); +const CACHE_NAMESPACE = 'cache'; +const CACHE_KEY = 'hn-stories'; +const CACHE_TTL_SECONDS = 60 * 60 * 24; + +const kv = new KeyValueClient(); +const app = new Hono(); +app.post('/api/workers/refresh-hn', async (c) => { const idsRes = await fetch('https://hacker-news.firebaseio.com/v0/topstories.json'); const ids = TopStoryIdsSchema.parse(await idsRes.json()); @@ -52,22 +56,23 @@ router.post('/digest', cron('0 * * * *', { auth: true }, async (c) => { // [!cod title: story.title, score: story.score, by: story.by, - url: story.url || `https://news.ycombinator.com/item?id=${story.id}`, + url: story.url ?? `https://news.ycombinator.com/item?id=${story.id}`, }; }) ); - await c.var.kv.set('cache', 'hn-stories', { // [!code highlight] + const cached: CachedStories = { stories, fetchedAt: new Date().toISOString(), - }, { ttl: 86400 }); // [!code highlight] + }; - c.var.logger.info('Stories cached', { count: stories.length }); - return c.json({ success: true, count: stories.length }); -})); + await kv.set(CACHE_NAMESPACE, CACHE_KEY, cached, { ttl: CACHE_TTL_SECONDS }); -router.get('/stories', async (c) => { - const result = await c.var.kv.get('cache', 'hn-stories'); // [!code highlight] + return c.json({ ok: true, count: stories.length }); +}); + +app.get('/api/stories', async (c) => { + const result = await kv.get(CACHE_NAMESPACE, CACHE_KEY); if (!result.exists) { return c.json({ stories: [], fetchedAt: null }); @@ -76,104 +81,81 @@ router.get('/stories', async (c) => { return c.json(result.data); }); -export default router; +export default app; ``` -## Frontend - -A simple interface can read the cached result without calling the protected cron endpoint: +The worker accepts any payload from the schedule and ignores it. Verifying signatures or restricting the route to scheduled callers belongs in middleware if the worker should not be publicly reachable. -```tsx title="src/web/App.tsx" -import { useState, useEffect } from 'react'; +## Create the Schedule -interface Story { - id: number; - title: string; - score: number; - by: string; - url: string; -} +The schedule is created once with the SDK. The CLI subcommand `agentuity cloud schedule create` does not accept destinations, so do this from a one-time script rather than an interactive flow. -export function App() { - const [stories, setStories] = useState([]); - const [fetchedAt, setFetchedAt] = useState(null); - const [isLoading, setIsLoading] = useState(false); +```typescript title="scripts/create-schedule.ts" +import { ScheduleClient } from '@agentuity/schedule'; +import { logger } from '@agentuity/telemetry'; - const loadStories = async () => { - const res = await fetch('/api/hn/stories'); // [!code highlight] - const data = await res.json(); - setStories(data.stories || []); - setFetchedAt(data.fetchedAt); - }; +const schedules = new ScheduleClient(); - const refreshStories = async () => { - setIsLoading(true); - await loadStories(); - setIsLoading(false); - }; +const { schedule } = await schedules.create({ + name: 'Refresh Hacker News cache', + description: 'Calls the refresh worker every hour', + expression: '0 * * * *', + destinations: [ + { + type: 'url', + config: { + url: 'https:///api/workers/refresh-hn', + method: 'POST', + }, + }, + ], +}); - useEffect(() => { - loadStories(); - }, []); - - return ( -
-

HN Top Stories

- -
- - {fetchedAt && ( - - Last updated: {new Date(fetchedAt).toLocaleString()} - - )} -
- - {stories.length === 0 ? ( -

No stories cached yet.

- ) : ( -
    - {stories.map((story) => ( -
  • - - {story.title} - -
    - {story.score} points by {story.by} -
    -
  • - ))} -
- )} -
- ); -} +logger.info('schedule created', { id: schedule.id }); ``` -## Testing Locally +`schedules.create()` is atomic: a bad destination URL rolls back the whole call. Run the script once from a deployed environment, or against a public tunnel for local testing. -Cron schedules only trigger in deployed environments. Locally, you can read cached data with the GET route: + +The platform calls the destination URL. `localhost` is unreachable. For local iteration, expose the dev server with a tunnel like ngrok and point the destination at the tunnel URL. + -```bash -curl http://localhost:3500/api/hn/stories +## Inspect Recent Deliveries + +Each schedule firing produces a delivery record. Use it for audit and debugging. + +```typescript +const recent = await schedules.listDeliveries(schedule.id, { limit: 20 }); + +for (const delivery of recent.deliveries) { + logger.info('delivery', { + id: delivery.id, + status: delivery.status, + date: delivery.date, + }); +} ``` -To exercise the cron handler locally, temporarily set `{ auth: false }` or send a signed request with `X-Agentuity-Cron-Signature` and `X-Agentuity-Cron-Timestamp`. Keep `{ auth: true }` for deployed cron routes. +`delivery.date` is the ISO 8601 timestamp when the platform attempted to deliver. `delivery.error` and `delivery.response` carry the destination's reply (or the failure reason). -## Key Points +## Update or Delete -- **`cron()` middleware** wraps POST handlers with a schedule expression -- **KV with TTL** automatically expires stale data (24 hours in this example) -- **Separate GET endpoint** lets clients retrieve cached results without cron auth -- **Local testing** requires a manual POST because schedules only run when deployed +Change the cron expression or description with `update()`. Removing the schedule also cancels future deliveries. - -See the [Scheduled Digest](https://github.com/agentuity/examples/tree/main/features/scheduled-digest) example for a Hacker News + GitHub digest using cron, KV storage, and durable streams. - +```typescript +await schedules.update(schedule.id, { expression: '0 */2 * * *' }); +await schedules.delete(schedule.id); +``` + +## Notes + +- the schedule owns the timer, key-value storage owns the cache, and the GET route owns the public read +- set `ttl` longer than the schedule interval so a missed run does not produce an empty cache +- write the entire payload as one key when reads should be atomic; split into multiple keys when callers want to read parts independently +- `update()` recomputes the next run time the moment the new expression is saved -## See Also +## Next Steps -- [Cron Routes](/routes/cron) for schedule expressions and patterns -- [Key-Value Storage](/services/storage/key-value) for KV operations and TTL options +- [Schedules](/services/schedules): destinations, deliveries, and platform behavior +- [Key-Value Storage](/services/storage/key-value): TTL, namespaces, and search +- [Background Work](/build/background-work): wire request, status, worker, and stream routes around a queue diff --git a/docs/src/web/content/cookbook/patterns/hono-rpc-tanstack-query.mdx b/docs/src/web/content/cookbook/patterns/hono-rpc-tanstack-query.mdx index 0de7602eb..694b622a6 100644 --- a/docs/src/web/content/cookbook/patterns/hono-rpc-tanstack-query.mdx +++ b/docs/src/web/content/cookbook/patterns/hono-rpc-tanstack-query.mdx @@ -1,371 +1,294 @@ --- title: Type-Safe API Calls with Hono RPC and TanStack Query short_title: Hono RPC + TanStack -description: Get end-to-end type safety between your Agentuity API routes and React frontend using Hono RPC and TanStack Query +description: Share Hono route types with a React client and wrap calls in TanStack Query --- -Use [Hono RPC](https://hono.dev/docs/guides/rpc) when your React frontend should call Agentuity API routes with route-inferred request and response types. Add [TanStack Query](https://tanstack.com/query) when you want caching, request state, and invalidation around those calls. - -## Overview - -Hono RPC infers client types directly from your route definitions. Combined with TanStack Query, you get: - -- **Type-safe API calls**: request params, body, and response types are inferred from route handlers -- **Caching and revalidation**: TanStack Query manages server state -- **One route contract**: the route definition is the source of truth - -## Installation +Use [Hono RPC](https://hono.dev/docs/guides/rpc) when a React client should call a Hono route with inferred request and response types. Add [TanStack Query](https://tanstack.com/query) when the UI needs caching, request state, and invalidation around those calls. ```bash -bun add @tanstack/react-query zod @hono/zod-validator +bun add hono @tanstack/react-query zod @hono/zod-validator ``` - -Agentuity templates already include `hono` as an app dependency. If your project does not, add it too because the browser imports `hc` from `hono/client`. - - -## Server: Define Typed Routes +## Define Typed Routes -Use method chaining on `new Hono()` so TypeScript can infer the full route tree: +Chain route methods on the same `Hono` instance. Hono infers the client contract from that chained route tree. ```typescript title="src/api/users.ts" import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; -const CreateUserSchema = z.object({ - name: z.string().min(1), - email: z.string().email(), +const createUserSchema = z.object({ + name: z.string().min(1), + email: z.string().email(), }); -const router = new Hono() - .get('/users', async (c) => { - // In a real app, query your database here - const users = [ - { id: '1', name: 'Alice', email: 'alice@example.com' }, - { id: '2', name: 'Bob', email: 'bob@example.com' }, - ]; - return c.json({ users }); - }) - .get('/users/:id', async (c) => { - const id = c.req.param('id'); - return c.json({ id, name: 'Alice', email: 'alice@example.com' }); - }) - .post( - '/users', - zValidator('json', CreateUserSchema), - async (c) => { - const body = c.req.valid('json'); - const user = { id: crypto.randomUUID(), ...body }; - return c.json({ user }, 201); - } - ) - .delete('/users/:id', async (c) => { - const id = c.req.param('id'); - return c.json({ deleted: id }); - }); +const router = new Hono() + .get('/users', async (c) => { + const users = [ + { id: '1', name: 'Alice', email: 'alice@example.com' }, + { id: '2', name: 'Bob', email: 'bob@example.com' }, + ]; + + return c.json({ users }); + }) + .get('/users/:id', async (c) => { + const id = c.req.param('id'); + return c.json({ id, name: 'Alice', email: 'alice@example.com' }); + }) + .post('/users', zValidator('json', createUserSchema), async (c) => { + const body = c.req.valid('json'); + const user = { id: crypto.randomUUID(), ...body }; + + return c.json({ user }, 201); + }) + .delete('/users/:id', async (c) => { + const id = c.req.param('id'); + return c.json({ deleted: id }); + }); -// Export the type the client imports export type UsersRoute = typeof router; export default router; ``` - -Chain `.get()`, `.post()`, etc. directly on `new Hono()`. If you use separate `router.get(...)` statements, TypeScript can't infer the combined type. + +Route files can import service clients, read environment variables, or use server-only framework APIs. Export their types through a type-only file so the browser never imports the route module as runtime code. -## Export Route Types to the Client - - -Use the type-only barrel shown below for client code. Type-only imports and exports are erased at compile time, while value imports can pull server-only code into the browser bundle. - +## Export Route Types -Use a dedicated type-only file for frontend imports: +Use a dedicated type-only file for client imports: ```typescript title="src/shared/api-types.ts" -// Keep this file type-only export type { UsersRoute } from '../api/users'; export type { PostsRoute } from '../api/posts'; ``` -Then import from that file in the frontend: +Then create the browser client: -```typescript title="src/web/api.ts" +```typescript title="src/client/api.ts" import { hc } from 'hono/client'; import type { UsersRoute } from '../shared/api-types'; -export const client = hc('/api'); +export const usersClient = hc('/api'); ``` -This works because: - -1. `src/shared/api-types.ts` uses only `export type { ... }`, so TypeScript erases it at compile time. -2. `verbatimModuleSyntax: true` keeps `import type` and `export type` honest instead of rewriting them into value imports. -3. Your server route file stays out of the browser bundle. - - -A file like `src/api/users.ts` may import `@agentuity/runtime`, database clients, or read `process.env` at the top level. Keep those files behind a type-only barrel so the frontend never points at the server module directly. - +The `hc()` base URL must match where the route is mounted. For a Hono app mounted at `/api`, use `'/api'`. For a framework proxy or deployed app with a different base path, use that path instead. -## Client: Use with TanStack Query +## Add TanStack Query -### Setup the Query Provider +Wrap your React app once: -```tsx title="src/web/frontend.tsx" -import React from 'react'; -import ReactDOM from 'react-dom/client'; +```tsx title="src/client/main.tsx" import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createRoot } from 'react-dom/client'; import { App } from './App'; const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - }, - }, + defaultOptions: { + queries: { + staleTime: 30_000, + }, + }, }); const root = document.getElementById('root'); if (!root) { - throw new Error('Root element not found'); + throw new Error('Root element not found'); } -ReactDOM.createRoot(root).render( - - - - - +createRoot(root).render( + + + ); ``` -### Query Hooks +Create query and mutation hooks around the Hono client: -```typescript title="src/web/hooks/useUsers.ts" -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '../api'; +```typescript title="src/client/use-users.ts" +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { usersClient } from './api'; -// Fetch all users export function useUsers() { - return useQuery({ - queryKey: ['users'], - queryFn: async () => { - const res = await client.users.$get(); - if (!res.ok) throw new Error('Failed to fetch users'); - return res.json(); - // ^? { users: { id: string; name: string; email: string }[] } - }, - }); + return useQuery({ + queryKey: ['users'], + queryFn: async () => { + const res = await usersClient.users.$get(); + + if (!res.ok) { + throw new Error(`Failed to fetch users: ${res.status}`); + } + + return res.json(); + }, + }); } -// Fetch a single user by ID export function useUser(id: string) { - return useQuery({ - queryKey: ['users', id], - queryFn: async () => { - const res = await client.users[':id'].$get({ param: { id } }); - if (!res.ok) throw new Error('Failed to fetch user'); - return res.json(); - // ^? { id: string; name: string; email: string } - }, - enabled: !!id, - }); + return useQuery({ + queryKey: ['users', id], + queryFn: async () => { + const res = await usersClient.users[':id'].$get({ param: { id } }); + + if (!res.ok) { + throw new Error(`Failed to fetch user: ${res.status}`); + } + + return res.json(); + }, + enabled: id.length > 0, + }); } -// Create a new user export function useCreateUser() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (data: { name: string; email: string }) => { - const res = await client.users.$post({ json: data }); - if (!res.ok) throw new Error('Failed to create user'); - return res.json(); - }, - onSuccess: () => { - // Invalidate the users list so it refetches - queryClient.invalidateQueries({ queryKey: ['users'] }); - }, - }); -} + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: { readonly name: string; readonly email: string }) => { + const res = await usersClient.users.$post({ json: data }); + + if (!res.ok) { + throw new Error(`Failed to create user: ${res.status}`); + } -// Delete a user -export function useDeleteUser() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (id: string) => { - const res = await client.users[':id'].$delete({ param: { id } }); - if (!res.ok) throw new Error('Failed to delete user'); - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['users'] }); - }, - }); + return res.json(); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['users'] }); + }, + }); } ``` -### Use in Components +Use the hooks from a component: -```tsx title="src/web/components/UserList.tsx" -import { useUsers, useCreateUser, useDeleteUser } from '../hooks/useUsers'; +```tsx title="src/client/UserList.tsx" import { useState } from 'react'; +import { useCreateUser, useUsers } from './use-users'; export function UserList() { - const { data, isLoading, error } = useUsers(); - const createUser = useCreateUser(); - const deleteUser = useDeleteUser(); - const [name, setName] = useState(''); - const [email, setEmail] = useState(''); - - if (isLoading) return
Loading...
; - if (error) return
Error: {error.message}
; - - return ( -
-
{ - e.preventDefault(); - createUser.mutate({ name, email }); - setName(''); - setEmail(''); - }} - > - setName(e.target.value)} placeholder="Name" /> - setEmail(e.target.value)} placeholder="Email" /> - -
- -
    - {data?.users.map((user) => ( -
  • - {user.name} ({user.email}) - -
  • - ))} -
-
- ); + const { data, error, isLoading } = useUsers(); + const createUser = useCreateUser(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + + if (isLoading) { + return
Loading...
; + } + + if (error) { + return
Error: {error.message}
; + } + + return ( +
+
{ + event.preventDefault(); + createUser.mutate({ name, email }); + setName(''); + setEmail(''); + }} + > + setName(event.target.value)} /> + setEmail(event.target.value)} /> + +
+ +
    + {data?.users.map((user) => ( +
  • + {user.name} ({user.email}) +
  • + ))} +
+
+ ); } ``` -## Multiple Route Files +## Compose Multiple Routers -When you have multiple route files, export all types from a shared file: +When you split routes across files, compose them with Hono and export the composed type: ```typescript title="src/api/posts.ts" import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; -const router = new Hono() - .get('/posts', async (c) => { - return c.json({ posts: [] }); - }) - .get('/posts/:id', async (c) => { - return c.json({ id: c.req.param('id'), title: '', body: '' }); - }); +const router = new Hono() + .get('/posts', async (c) => { + return c.json({ posts: [] }); + }) + .get('/posts/:id', async (c) => { + return c.json({ id: c.req.param('id'), title: '', body: '' }); + }); export type PostsRoute = typeof router; export default router; ``` -```typescript title="src/shared/api-types.ts" -export type { UsersRoute } from '../api/users'; -export type { PostsRoute } from '../api/posts'; // [!code highlight] -``` - -```typescript title="src/web/api.ts" -import { hc } from 'hono/client'; -import type { UsersRoute, PostsRoute } from '../shared/api-types'; // [!code highlight] - -export const usersClient = hc('/api'); // [!code highlight] -export const postsClient = hc('/api'); // [!code highlight] -``` - -## With Explicit Routing - -If you're mounting sub-routers with `createApp({ router })`, export the composed router type. Mount routers at `/` when the imported route files already include their path prefixes. - ```typescript title="src/api/index.ts" import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; -import users from './users'; import posts from './posts'; +import users from './users'; -const router = new Hono() - .route('/', users) - .route('/', posts); - -export type AppRoute = typeof router; -export default router; -``` - -If you prefer `.route('/users', users)`, define the users router with relative paths like `/` and `/:id`. Otherwise Hono will compose doubled paths such as `/users/users`. - -```typescript title="app.ts" -import { createApp } from '@agentuity/runtime'; // [!code highlight] -import router from './src/api/index'; // [!code highlight] - -const app = await createApp({ router }); // [!code highlight] +const app = new Hono() + .route('/', users) + .route('/', posts); +export type AppRoute = typeof app; export default app; ``` ```typescript title="src/shared/api-types.ts" -export type { AppRoute } from '../api/index'; +export type { AppRoute } from '../api'; ``` -```typescript title="src/web/api.ts" +```typescript title="src/client/api.ts" import { hc } from 'hono/client'; import type { AppRoute } from '../shared/api-types'; export const client = hc('/api'); ``` -For custom mount paths: +If you mount a router at `.route('/users', users)`, define the users router with relative paths like `/` and `/:id`. Otherwise Hono composes doubled paths such as `/users/users`. -```typescript title="app.ts" -import { createApp } from '@agentuity/runtime'; -import myRouter from './src/api/index'; +## With Agentuity Services -const app = await createApp({ - router: { path: '/v1', router: myRouter }, // [!code highlight] -}); +Hono RPC works with ordinary Hono route handlers. If the route needs Agentuity service clients, install `@agentuity/hono` and add the middleware before the route handlers that read `c.var.*`. -export default app; +```bash +bun add @agentuity/hono@alpha ``` -```typescript title="src/web/api.ts" -export const client = hc('/v1'); // [!code highlight] -``` +```typescript title="src/api/index.ts" +import { Hono } from 'hono'; +import { agentuity } from '@agentuity/hono'; +import type { Services } from '@agentuity/hono'; -## Why Validation Matters +type Variables = Pick; -Hono RPC infers request body types from validator middleware. A `c.req.json()` annotation only types the local server value, so use `zValidator()` and `c.req.valid('json')` when the client should know the request shape. +const app = new Hono<{ Variables: Variables }>(); -This is separate from Agentuity agent schemas, where `@agentuity/schema` is the lightweight default. For Hono middleware, [Hono's Zod validator](https://hono.dev/docs/guides/validation#with-zod) is the direct integration. +app.use('*', agentuity()); -The client mutation gets the route-inferred body shape. Content rules such as `.email()` still run on the server: +app.get('/sessions/:id', async (c) => { + const result = await c.var.kv.get('sessions', c.req.param('id')); + return c.json(result.exists ? result.data : null); +}); -```typescript -createUser.mutate({ name: '', email: 'not-an-email' }); // runtime validation catches this -createUser.mutate({ wrong: 'field' }); // compile-time error +export type AppRoute = typeof app; +export default app; ``` ## Tips -- **Chain methods** on `new Hono()`: separate statements lose type inference -- **Use type-only imports**: import route types with `import type`, or use a type-only shared barrel for stable paths -- **Match mount paths**: the `hc()` base URL must match where the router is mounted -- **Check responses**: always check `res.ok` before calling `res.json()` in query functions -- **Use hierarchical query keys**: keys like `['users']` and `['users', id]` make invalidation precise +- chain route methods on the same `Hono` instance when you want RPC inference +- import route types with `import type` or a type-only barrel +- keep the `hc()` base URL aligned with your framework proxy or route mount +- check `res.ok` before calling `res.json()` in query functions +- use validators such as `zValidator()` when the client should know the request body shape diff --git a/docs/src/web/content/cookbook/patterns/llm-as-a-judge.mdx b/docs/src/web/content/cookbook/patterns/llm-as-a-judge.mdx index 1873781b2..80fc42731 100644 --- a/docs/src/web/content/cookbook/patterns/llm-as-a-judge.mdx +++ b/docs/src/web/content/cookbook/patterns/llm-as-a-judge.mdx @@ -1,109 +1,84 @@ --- title: LLM as a Judge -description: Use LLMs to evaluate and score agent outputs for quality, safety, and compliance +description: Use a model to evaluate another model's output for quality, grounding, or comparison --- -The LLM-as-judge pattern uses one model to evaluate another model's output. This is useful for subjective quality assessments that can't be checked programmatically. +LLM-as-judge is the right tool when the rubric needs context, nuance, or subjective comparison. It is the wrong tool when the check is structural (JSON shape, length, presence of a field). For those, validate with a schema and skip the model call. -## When to Use This Pattern +| Use case | LLM-as-judge | Code or schema | +|---|---|---| +| Factual grounding against retrieved sources | Yes | | +| Tone, helpfulness, politeness | Yes | | +| Picking a winner between two outputs | Yes | | +| Hallucination detection in RAG | Yes | | +| Validating JSON structure | | Yes | +| Word count, regex match | | Yes | -| Use Case | Good Fit? | -|----------|-----------| -| Checking factual accuracy against sources | Yes | -| Evaluating tone and politeness | Yes | -| Detecting hallucinations in RAG | Yes | -| Comparing response quality | Yes | -| Validating JSON structure | No (use schema validation) | -| Checking string length | No (use code) | - -Use LLM-as-judge when the evaluation requires understanding context, nuance, or subjective criteria. - -## Inline vs Background Checks - -LLM-as-judge can be used in two contexts: - -| Context | When to Use | Blocks Response? | -|---------|-------------|------------------| -| **Inline** (in handler) | Scores returned with the response, model comparison, or request-time gating | Yes | -| **Background** (`waitUntil()`) | Quality monitoring, compliance checks, or review flows after the response is sent | No | - -**Inline example**: A model arena that shows users which response "won" needs scores returned with the response. - -**Background example**: Checking responses for PII or hallucinations without adding latency to the main response. - - -Run LLM-as-judge inline when the score is part of the response. Use `waitUntil()` when the result is for monitoring, review, or follow-up work. - +```bash +bun add ai @ai-sdk/openai @ai-sdk/anthropic @ai-sdk/groq @agentuity/queue@alpha @agentuity/vector@alpha @agentuity/schema@alpha zod +``` -## Basic Pattern +## Inline Quality Check -Use a fast model to judge outputs. Groq provides low-latency inference for judge calls: +Run the judge in the same request when the score is part of the response. Fast judge models keep the added latency manageable. -```typescript -import { createAgent } from '@agentuity/runtime'; -import { generateText, generateObject } from 'ai'; +```typescript title="src/lib/quality-check.ts" import { openai } from '@ai-sdk/openai'; import { groq } from '@ai-sdk/groq'; +import { generateText, Output } from 'ai'; import { z } from 'zod'; -const JudgmentSchema = z.object({ +const judgmentSchema = z.object({ score: z.number().min(0).max(1).describe('Quality score from 0 to 1'), - passed: z.boolean().describe('Whether the response meets quality threshold'), - reason: z.string().describe('Brief explanation of the judgment'), + passed: z.boolean().describe('Whether the response meets the threshold'), + reason: z.string().describe('Brief explanation'), }); -const agent = createAgent('QualityChecker', { - schema: { - input: z.object({ question: z.string() }), - output: z.object({ - answer: z.string(), - judgment: JudgmentSchema, - }), - }, - handler: async (ctx, input) => { - // Generate answer with primary model - const { text: answer } = await generateText({ - model: openai('gpt-5-mini'), - prompt: input.question, - }); - - // Judge with fast model - const { object: judgment } = await generateObject({ - model: groq('openai/gpt-oss-120b'), - schema: JudgmentSchema, - prompt: `Evaluate this response on a 0-1 scale. - -Question: ${input.question} +interface QualityResult { + readonly answer: string; + readonly judgment: z.infer; +} + +export async function answerWithQualityCheck(question: string): Promise { + const { text: answer } = await generateText({ + model: openai('gpt-5.4-mini'), + prompt: question, + }); + + const { output: judgment } = await generateText({ + model: groq('meta-llama/llama-4-scout-17b-16e-instruct'), + output: Output.object({ schema: judgmentSchema }), + prompt: `Evaluate this response on a 0-1 scale. + +Question: ${question} Response: ${answer} Consider: - Does it directly answer the question? - Is the information accurate? -- Is it clear and easy to understand? +- Is it clear and easy to read? Score 0.7+ to pass.`, - }); - - return { answer, judgment }; - }, -}); + }); -export default agent; + return { answer, judgment }; +} ``` -## Model Comparison Arena +The judge model is intentionally smaller and faster than the answer model. Inline judging is for cases where the user sees the score; otherwise move it off the request path. + +## Model Arena -Compare responses from multiple providers and declare a winner. This pattern is useful for benchmarking, A/B testing, or letting users see quality differences: +Compare two providers side-by-side and let a third model pick the winner. Run the candidates in parallel, then judge. -```typescript -import { createAgent } from '@agentuity/runtime'; -import { generateText, generateObject } from 'ai'; +```typescript title="src/lib/model-arena.ts" import { openai } from '@ai-sdk/openai'; import { anthropic } from '@ai-sdk/anthropic'; import { groq } from '@ai-sdk/groq'; +import { generateText, Output, type LanguageModel } from 'ai'; import { z } from 'zod'; -const ArenaJudgment = z.object({ +const arenaJudgment = z.object({ winner: z.enum(['openai', 'anthropic']), reasoning: z.string(), scores: z.object({ @@ -112,40 +87,30 @@ const ArenaJudgment = z.object({ }), }); -const arenaAgent = createAgent('ModelArena', { - schema: { - input: z.object({ - prompt: z.string(), - tone: z.enum(['whimsical', 'suspenseful', 'comedic']), - }), - output: z.object({ - results: z.array(z.object({ - provider: z.string(), - story: z.string(), - generationMs: z.number(), - })), - judgment: ArenaJudgment, - }), - }, - handler: async (ctx, input) => { - // Generate from both models in parallel - const [openaiResult, anthropicResult] = await Promise.all([ - generateWithTiming(openai('gpt-5.4-nano'), input.prompt, input.tone), - generateWithTiming(anthropic('claude-haiku-4-5'), input.prompt, input.tone), - ]); - - const results = [ - { provider: 'openai', ...openaiResult }, - { provider: 'anthropic', ...anthropicResult }, - ]; +interface ArenaResult { + readonly results: ReadonlyArray<{ + readonly provider: 'openai' | 'anthropic'; + readonly story: string; + readonly generationMs: number; + }>; + readonly judgment: z.infer; +} + +export async function runArena( + prompt: string, + tone: 'whimsical' | 'suspenseful' | 'comedic' +): Promise { + const [openaiResult, anthropicResult] = await Promise.all([ + generateWithTiming(openai('gpt-5.4-nano'), prompt, tone), + generateWithTiming(anthropic('claude-haiku-4-5'), prompt, tone), + ]); - // Judge with fast model - const { object: judgment } = await generateObject({ - model: groq('openai/gpt-oss-120b'), - schema: ArenaJudgment, - prompt: `Compare these ${input.tone} stories and pick a winner. + const { output: judgment } = await generateText({ + model: groq('meta-llama/llama-4-scout-17b-16e-instruct'), + output: Output.object({ schema: arenaJudgment }), + prompt: `Compare these ${tone} stories and pick a winner. -PROMPT: "${input.prompt}" +PROMPT: "${prompt}" --- OpenAI --- ${openaiResult.story} @@ -153,20 +118,23 @@ ${openaiResult.story} --- Anthropic --- ${anthropicResult.story} -Score each on creativity, engagement, and tone match (0-1). -Declare a winner with reasoning.`, - }); +Score each on creativity, engagement, and tone match (0-1).`, + }); - ctx.logger.info('Arena complete', { winner: judgment.winner }); - return { results, judgment }; - }, -}); + return { + results: [ + { provider: 'openai', ...openaiResult }, + { provider: 'anthropic', ...anthropicResult }, + ], + judgment, + }; +} async function generateWithTiming( - model: Parameters[0]['model'], + model: LanguageModel, prompt: string, tone: string -) { +): Promise<{ story: string; generationMs: number }> { const start = Date.now(); const { text } = await generateText({ model, @@ -175,213 +143,188 @@ async function generateWithTiming( }); return { story: text, generationMs: Date.now() - start }; } - -export default arenaAgent; ``` - -The [SDK Explorer](/explorer/model-arena) includes a working Model Arena demo that demonstrates this pattern in more detail. - - ## Grounding Check for RAG -Detect hallucinations by checking if claims are supported by retrieved sources: +Score whether an answer is supported by the retrieved sources. Returning the unsupported claims back to the caller turns the judge into a hallucination filter. -```typescript -import { createAgent } from '@agentuity/runtime'; -import { generateText, generateObject } from 'ai'; +```typescript title="src/lib/rag-grounding.ts" +import { VectorClient } from '@agentuity/vector'; import { openai } from '@ai-sdk/openai'; import { groq } from '@ai-sdk/groq'; +import { generateText, Output } from 'ai'; import { z } from 'zod'; -const GroundingJudgment = z.object({ - isGrounded: z.boolean().describe('Whether all claims are supported by sources'), - score: z.number().min(0).max(1).describe('Percentage of claims supported'), - unsupportedClaims: z.array(z.string()).describe('Claims not found in sources'), +type DocumentMetadata = { + readonly content: string; + readonly title: string; +} & Record; + +const groundingJudgment = z.object({ + isGrounded: z.boolean(), + score: z.number().min(0).max(1).describe('Fraction of claims supported by sources'), + unsupportedClaims: z.array(z.string()), reason: z.string(), }); -interface DocumentMetadata extends Record { - content: string; - title: string | undefined; +interface GroundedAnswer { + readonly answer: string; + readonly grounding: z.infer; + readonly sources: readonly string[]; } -const ragAgent = createAgent('RAGWithGrounding', { - schema: { - input: z.object({ question: z.string() }), - output: z.object({ - answer: z.string(), - grounding: GroundingJudgment, - sources: z.array(z.string()), - }), - }, - handler: async (ctx, input) => { - // Retrieve relevant documents - const results = await ctx.vector.search('knowledge-base', { - query: input.question, - limit: 3, - }); - - const sources = results.map(r => r.metadata?.content || '').filter(Boolean); - - // Generate answer - const { text: answer } = await generateText({ - model: openai('gpt-5-mini'), - prompt: `Answer based on these sources:\n\n${sources.join('\n\n')}\n\nQuestion: ${input.question}`, - }); - - // Check grounding with fast model - const { object: grounding } = await generateObject({ - model: groq('openai/gpt-oss-120b'), - schema: GroundingJudgment, - prompt: `Check if this answer is supported by the sources. +const vector = new VectorClient(); -Answer: ${answer} +export async function answerWithGrounding(question: string): Promise { + const matches = await vector.search('knowledge-base', { + query: question, + limit: 3, + }); -Sources: -${sources.map((s, i) => `[${i + 1}] ${s}`).join('\n\n')} + const sources = matches + .map((match) => match.metadata?.content ?? '') + .filter((text) => text.length > 0); -Are all factual claims in the answer supported by the sources? -List any unsupported claims.`, - }); + const { text: answer } = await generateText({ + model: openai('gpt-5.4-mini'), + prompt: `Answer using these sources:\n\n${sources.join('\n\n')}\n\nQuestion: ${question}`, + }); - if (!grounding.isGrounded) { - ctx.logger.warn('Hallucination detected', { - claims: grounding.unsupportedClaims, - }); - } + const { output: grounding } = await generateText({ + model: groq('meta-llama/llama-4-scout-17b-16e-instruct'), + output: Output.object({ schema: groundingJudgment }), + prompt: `Check whether this answer is supported by the sources. - return { answer, grounding, sources: results.map(r => r.key) }; - }, -}); +Answer: ${answer} + +Sources: +${sources.map((source, i) => `[${i + 1}] ${source}`).join('\n\n')} + +Return any factual claim in the answer that is not supported by the sources.`, + }); -export default ragAgent; + return { + answer, + grounding, + sources: matches.map((match) => match.key), + }; +} ``` -## Run in the Background +Pair this with [Build a RAG Agent](/cookbook/tutorials/rag-agent) when answers should be filtered or flagged before they reach the user. -For background quality monitoring, queue the judge with `ctx.waitUntil()` so the response returns immediately: +## Background Judging -```typescript title="src/agent/qa-agent/agent.ts" -import { createAgent } from '@agentuity/runtime'; -import { generateText, generateObject } from 'ai'; +For monitoring and review flows, the judge does not need to block the response. Publish to a queue and let a worker route do the scoring. + +```typescript title="src/index.ts" +import { Hono } from 'hono'; +import { QueueClient } from '@agentuity/queue'; +import { logger } from '@agentuity/telemetry'; import { openai } from '@ai-sdk/openai'; import { groq } from '@ai-sdk/groq'; +import { generateText, Output } from 'ai'; import { z } from 'zod'; -const HelpfulnessJudgment = z.object({ - scores: z.object({ - helpfulness: z.object({ - score: z.number().min(0).max(1), - reason: z.string(), - }), - }), - checks: z.object({ - answersQuestion: z.object({ - passed: z.boolean(), - reason: z.string(), - }), - actionable: z.object({ - passed: z.boolean(), - reason: z.string(), - }), - }), +const helpfulnessJudgment = z.object({ + score: z.number().min(0).max(1), + answersQuestion: z.boolean(), + actionable: z.boolean(), + reason: z.string(), }); -const agent = createAgent('QAAgent', { - schema: { - input: z.object({ question: z.string() }), - output: z.object({ answer: z.string() }), - }, - handler: async (ctx, input) => { - const { text: answer } = await generateText({ - model: openai('gpt-5-mini'), - prompt: input.question, - }); - - ctx.waitUntil(async () => { - const { object } = await generateObject({ - model: groq('openai/gpt-oss-120b'), - schema: HelpfulnessJudgment, - prompt: `Evaluate this response. - -Question: ${input.question} -Response: ${answer} +const judgePayload = z.object({ + question: z.string(), + answer: z.string(), +}); + +const queue = new QueueClient(); +const app = new Hono(); + +app.post('/api/answer', async (c) => { + const { question } = await c.req.json<{ question: string }>(); + + const { text: answer } = await generateText({ + model: openai('gpt-5.4-mini'), + prompt: question, + }); + + // hand the (question, answer) pair to the worker for background scoring + await queue.publish('answer-quality', { question, answer }); + + return c.json({ answer }); +}); -SCORE: -- helpfulness: How useful is this response? (0 = useless, 1 = extremely helpful) +app.post('/api/workers/answer-quality', async (c) => { + const body = judgePayload.parse(await c.req.json()); -CHECKS: -- answersQuestion: Does it directly answer what was asked? -- actionable: Can someone act on this information?`, - }); + const { output } = await generateText({ + model: groq('meta-llama/llama-4-scout-17b-16e-instruct'), + output: Output.object({ schema: helpfulnessJudgment }), + prompt: `Score this answer. - ctx.logger.info('Judge completed', { - helpfulness: object.scores.helpfulness.score, - answersQuestion: object.checks.answersQuestion.passed, - actionable: object.checks.actionable.passed, - }); - }); +Question: ${body.question} +Answer: ${body.answer} - return { answer }; - }, +- score: 0 = useless, 1 = extremely helpful +- answersQuestion: directly addresses the question? +- actionable: can the user act on this?`, + }); + + logger.info('judge complete', { + score: output.score, + answersQuestion: output.answersQuestion, + }); + + return c.json({ ok: true }); }); -export default agent; +export default app; ``` -## Structuring Judge Prompts +The worker route is fired by an HTTP destination on the `answer-quality` queue. See [Background Work](/build/background-work) for the full request/status/worker shape. -Good judge prompts have clear structure: +## Prompt Structure -```typescript -const judgePrompt = `You are evaluating a ${taskType} response. +Judge prompts work better when the rubric is explicit and the scale is fixed. + +```text +You are evaluating a {taskType} response. CONTEXT: -${context} +{context} RESPONSE TO EVALUATE: -${response} +{response} -SCORING CRITERIA (0.0-1.0): -- criterion1: What specifically to look for -- criterion2: What specifically to look for +SCORING (0.0-1.0): +- criterion1: what to look for +- criterion2: what to look for CHECKS (pass/fail): -- check1: Specific yes/no question -- check2: Specific yes/no question +- check1: yes/no question with clear answer +- check2: yes/no question with clear answer -Evaluate each criterion, then provide an overall assessment.`; +Return scores and a one-sentence reason. ``` -**Tips:** -- Be explicit about the scale (0-1, not 1-10) -- Define what each score level means -- Make checks binary questions with clear answers -- Include the original context/prompt for reference - -## Best Practices - -- **Use lower-latency judge models**: examples here use Groq `openai/gpt-oss-120b`, OpenAI `gpt-5.4-nano`, and Anthropic `claude-haiku-4-5` -- **Separate scores from checks**: Scores for gradients, checks for pass/fail -- **Structure prompts clearly**: List criteria explicitly with descriptions -- **Log low scores**: Track failures for debugging and model improvement -- **Consider latency**: Use inline judging only when users need to see results - -## Cost Optimization +- pin a single scale ("0.0 to 1.0") rather than letting the model pick its own +- separate score-able criteria from binary checks +- include the original question or source in the prompt; the judge has no other context +- prefer faster judge models (Groq, smaller OpenAI, Haiku) for lower added latency -LLM-as-judge adds model calls. Optimize by: +## Cost Notes -- Using the fastest capable model for the rubric, such as Groq `openai/gpt-oss-120b` or a smaller provider model -- Running the judge asynchronously when the request path does not need a score -- Sampling a percentage of requests in high-volume scenarios -- Batching judgments when evaluating multiple items +LLM-as-judge doubles the model calls per request. Manage the cost by: - -See the [Code Runner](https://github.com/agentuity/examples/tree/main/features/code-runner) example for parallel sandbox execution with LLM-as-judge checks across TypeScript and Python. - +- using smaller, faster judge models (Groq scout, `gpt-5.4-nano`, `claude-haiku-4-5`) +- moving the judge off the request path with [Queues](/services/queues) when the score does not affect the response +- sampling a percentage of requests in high-volume traffic +- batching judgments when the rubric is the same across many items ## Next Steps -- [Using the AI SDK](/agents/ai-sdk-integration): Model selection and configuration -- [Vector Storage](/services/storage/vector): Build RAG systems to ground responses +- [Build a RAG Agent](/cookbook/tutorials/rag-agent): the answer pipeline this judge plugs into +- [Background Work](/build/background-work): wire the judge worker route to a queue with retries and status +- [AI Gateway](/services/ai-gateway): local dev routing and deployed provider env choices diff --git a/docs/src/web/content/cookbook/patterns/meta.json b/docs/src/web/content/cookbook/patterns/meta.json index b0fe091a4..3f255c12d 100644 --- a/docs/src/web/content/cookbook/patterns/meta.json +++ b/docs/src/web/content/cookbook/patterns/meta.json @@ -15,16 +15,16 @@ } ], "pages": [ - "autonomous-research", - "background-tasks", "chat-with-history", + "product-search", + "webhook-handler", "cron-with-storage", - "hono-rpc-tanstack-query", + "background-tasks", + "autonomous-research", "llm-as-a-judge", - "server-utilities", - "product-search", - "tailwind-setup", "web-exploration", - "webhook-handler" + "hono-rpc-tanstack-query", + "server-utilities", + "tailwind-setup" ] } diff --git a/docs/src/web/content/cookbook/patterns/product-search.mdx b/docs/src/web/content/cookbook/patterns/product-search.mdx index 049e9bbb0..0c369e865 100644 --- a/docs/src/web/content/cookbook/patterns/product-search.mdx +++ b/docs/src/web/content/cookbook/patterns/product-search.mdx @@ -1,450 +1,316 @@ --- title: Product Search with Vector short_title: Product Search -description: Semantic product search with metadata filtering +description: Semantic product search with metadata filters and an optional model recommendation --- -Build a product search that understands natural language queries and filters by category, price, or other attributes. +A product search built on vector storage understands natural language ("comfortable office chair under $300") instead of matching exact tokens. This pattern indexes products, searches by query, applies metadata filters in the route, and optionally asks a model to pick the best match. -## The Pattern - -Vector search finds semantically similar products. Combine with metadata filtering for precise results. - -```typescript title="src/agent/product-search/agent.ts" -import { createAgent } from '@agentuity/runtime'; -import { s } from '@agentuity/schema'; - -interface ProductMetadata extends Record { - name: string; - description: string; - price: number; - category: string; - inStock: boolean; -} - -const agent = createAgent('Product Search', { - description: 'Semantic search for products', - schema: { - input: s.object({ - query: s.string().describe('Natural language search query'), - category: s.optional(s.string()).describe('Filter by category'), - maxPrice: s.optional(s.number()).describe('Maximum price filter'), - limit: s.optional(s.number().min(1).max(50)).describe('Maximum results to return'), - }), - output: s.object({ - products: s.array(s.object({ - id: s.string(), - name: s.string(), - description: s.string(), - price: s.number(), - category: s.string(), - relevance: s.number(), - })), - total: s.number(), - }), - }, - handler: async (ctx, input) => { - const limit = input.limit ?? 10; - - ctx.logger.info('Searching products', { - query: input.query, - category: input.category, - maxPrice: input.maxPrice, - }); - - // Search with semantic similarity - const results = await ctx.vector.search('products', { // [!code highlight] - query: input.query, - limit: limit * 2, // Fetch extra for filtering - similarity: 0.6, - }); - - // Apply metadata filters - let filtered = results; - - if (input.category) { - filtered = filtered.filter(r => - r.metadata?.category?.toLowerCase() === input.category?.toLowerCase() - ); - } - - if (input.maxPrice !== undefined) { - const maxPrice = input.maxPrice; - filtered = filtered.filter(r => - (r.metadata?.price ?? Infinity) <= maxPrice - ); - } - - // Only show in-stock items - filtered = filtered.filter(r => r.metadata?.inStock !== false); - - // Limit to requested count - const products = filtered.slice(0, limit).map(r => ({ - id: r.key, - name: r.metadata?.name ?? 'Unknown', - description: r.metadata?.description ?? '', - price: r.metadata?.price ?? 0, - category: r.metadata?.category ?? 'Uncategorized', - relevance: r.similarity, - })); - - ctx.logger.info('Search complete', { - found: results.length, - afterFilters: products.length, - }); - - return { - products, - total: products.length, - }; - }, -}); - -export default agent; +```bash +bun add hono ai @ai-sdk/openai @agentuity/vector@alpha @agentuity/schema@alpha zod ``` -## Indexing Products +## Index Products -Add products to the vector database. Upserts are idempotent, so the same product ID can be indexed again after a catalog update. +Indexing is idempotent. Run it again on the same `id` to update the stored vector and metadata. -```typescript title="src/agent/product-indexer/agent.ts" -import { createAgent } from '@agentuity/runtime'; +```typescript title="src/lib/index-products.ts" +import { VectorClient } from '@agentuity/vector'; import { s } from '@agentuity/schema'; -const ProductSchema = s.object({ +const productSchema = s.object({ id: s.string(), name: s.string(), description: s.string(), price: s.number(), category: s.string(), + rating: s.optional(s.number()), inStock: s.optional(s.boolean()), }); -const agent = createAgent('ProductIndexer', { - schema: { - input: s.object({ - products: s.array(ProductSchema), - }), - output: s.object({ - indexed: s.number(), - }), - }, - handler: async (ctx, input) => { - for (const product of input.products) { - // Use description as the searchable document - await ctx.vector.upsert('products', { - key: product.id, - document: `${product.name}. ${product.description}`, - metadata: { - name: product.name, - description: product.description, - price: product.price, - category: product.category, - inStock: product.inStock ?? true, - }, - }); - } - - return { indexed: input.products.length }; - }, +const indexInputSchema = s.object({ + products: s.array(productSchema), }); -export default agent; +const VECTOR_NAMESPACE = 'products'; +const vector = new VectorClient(); + +export async function indexProducts(body: unknown): Promise<{ indexed: number }> { + const { products } = indexInputSchema.parse(body); + + for (const product of products) { + await vector.upsert(VECTOR_NAMESPACE, { + key: product.id, + // Concatenate name and description for better recall + document: `${product.name}. ${product.description}`, + metadata: { + name: product.name, + description: product.description, + price: product.price, + category: product.category, + rating: product.rating ?? 0, + inStock: product.inStock ?? true, + }, + }); + } + + return { indexed: products.length }; +} ``` -## Route with Query Parameters +## Search With Filters -Expose the basic search over `GET /api/products/search` and the advisor over `POST /api/products/advisor`: +`vector.search()` returns `id`, `key`, `similarity`, and optional `metadata`. Filter the result list by category, price, and availability after the search. -```typescript title="src/api/products/route.ts" -import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; -import productSearch from '@agent/product-search/agent'; -import productAdvisor from '@agent/product-advisor/agent'; - -const router = new Hono() - .get('/search', async (c) => { - const query = c.req.query('q') ?? ''; - const category = c.req.query('category'); - const maxPrice = c.req.query('maxPrice'); - const maxPriceParam = maxPrice?.trim(); - const maxPriceFilter = maxPriceParam ? Number(maxPriceParam) : undefined; - const parsedLimit = Number(c.req.query('limit') ?? '10'); - const maxPriceInput = - maxPriceFilter !== undefined && Number.isFinite(maxPriceFilter) ? maxPriceFilter : undefined; - const limitInput = Number.isFinite(parsedLimit) - ? Math.min(Math.max(parsedLimit, 1), 50) - : 10; - - const result = await productSearch.run({ - query, - category, - maxPrice: maxPriceInput, - limit: limitInput, - }); +Vector storage also accepts a `metadata: { ... }` filter passed to `search()` for exact-match scoping at the storage layer, but the filter must satisfy the full metadata type. Post-filtering in the route is simpler when the user-facing filters are optional or numeric. + +```typescript title="src/lib/search-products.ts" +import { VectorClient } from '@agentuity/vector'; +import { s } from '@agentuity/schema'; + +type ProductMetadata = { + readonly name: string; + readonly description: string; + readonly price: number; + readonly category: string; + readonly rating: number; + readonly inStock: boolean; +} & Record; + +const searchInputSchema = s.object({ + query: s.string(), + category: s.optional(s.string()), + maxPrice: s.optional(s.number()), + limit: s.optional(s.number()), +}); - return c.json(result); - }) - .post('/advisor', productAdvisor.validator(), async (c) => { - const input = c.req.valid('json'); - return c.json(await productAdvisor.run(input)); +type SearchInput = s.infer; + +interface ProductHit { + readonly id: string; + readonly name: string; + readonly description: string; + readonly price: number; + readonly category: string; + readonly rating: number; + readonly relevance: number; +} + +const VECTOR_NAMESPACE = 'products'; +const DEFAULT_LIMIT = 10; +const MAX_LIMIT = 50; +const MIN_SIMILARITY = 0.6; + +const vector = new VectorClient(); + +export async function searchProducts(body: unknown): Promise<{ + products: readonly ProductHit[]; + total: number; +}> { + const input = searchInputSchema.parse(body); + const limit = clampLimit(input.limit); + + // Pull extra so post-filter still has results + const matches = await vector.search(VECTOR_NAMESPACE, { + query: input.query, + limit: limit * 2, + similarity: MIN_SIMILARITY, + }); + + const filtered = matches + .filter((match) => match.metadata?.inStock !== false) + .filter((match) => + input.category === undefined + ? true + : match.metadata?.category?.toLowerCase() === input.category.toLowerCase() + ) + .filter((match) => + input.maxPrice === undefined ? true : (match.metadata?.price ?? Infinity) <= input.maxPrice + ) + .slice(0, limit); + + const products = filtered.map((match): ProductHit => { + const metadata = match.metadata; + return { + id: match.key, + name: metadata?.name ?? 'Unknown', + description: metadata?.description ?? '', + price: metadata?.price ?? 0, + category: metadata?.category ?? 'uncategorized', + rating: metadata?.rating ?? 0, + relevance: match.similarity, + }; }); -export default router; + return { products, total: products.length }; +} + +function clampLimit(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value)) return DEFAULT_LIMIT; + return Math.min(Math.max(Math.floor(value), 1), MAX_LIMIT); +} + +export type { SearchInput, ProductHit }; ``` -## AI-Powered Recommendations +A note on metadata generics: + +`vector.search()` requires `T extends Record`. Use a `type` intersection (`& Record`) so a normal interface with known fields satisfies the constraint. An interface declared without that intersection will fail typecheck. + +## Optional: Recommend the Best Match -Enhance search results with AI-generated recommendations. Use `generateObject` to analyze matches and suggest the best option. +When customers benefit from guidance ("which chair fits a tall desk?"), feed the top matches into a model and ask for one structured pick. `Output.object` validates the model output before it leaves the route. -```typescript title="src/agent/product-advisor/agent.ts" -import { createAgent } from '@agentuity/runtime'; -import { generateObject } from 'ai'; +```typescript title="src/lib/recommend-product.ts" import { openai } from '@ai-sdk/openai'; -import { s } from '@agentuity/schema'; +import { generateText, Output } from 'ai'; import { z } from 'zod'; +import { searchProducts, type ProductHit } from './search-products'; -interface ProductMetadata extends Record { - sku: string; - name: string; - price: number; - rating: number; - description: string; - feedback: string; -} +const recommendationSchema = z.object({ + recommendedId: z.string().describe('id field of the recommended product'), + rationale: z.string().describe('Two short sentences explaining why this product fits'), +}); -const agent = createAgent('Product Advisor', { - description: 'Semantic search with AI recommendations', - schema: { - input: s.object({ - query: s.string(), - }), - output: s.object({ - matches: s.array( - s.object({ - sku: s.string(), - name: s.string(), - price: s.number(), - rating: s.number(), - similarity: s.number(), - }) - ), - recommendation: s.string(), - recommendedSKU: s.string(), - }), - }, - handler: async (ctx, input) => { - // Semantic search for matching products - const results = await ctx.vector.search('products', { - query: input.query, - limit: 3, - similarity: 0.3, - }); +interface Recommendation { + readonly matches: readonly ProductHit[]; + readonly recommendedId: string; + readonly rationale: string; +} - if (results.length === 0) { - return { - matches: [], - recommendation: 'No matching products found. Try a different search.', - recommendedSKU: '', - }; - } - - // Format matches for response - const matches = results.map((r) => ({ - sku: r.metadata?.sku ?? '', - name: r.metadata?.name ?? '', - price: r.metadata?.price ?? 0, - rating: r.metadata?.rating ?? 0, - similarity: r.similarity, - })); - - // Build context for AI recommendation - const context = results - .map((r) => { - const p = r.metadata; - return `${p?.name}: SKU ${p?.sku}, $${p?.price}, ${p?.rating} stars. "${p?.feedback}"`; - }) - .join('\n'); - - // Generate personalized recommendation - const { object } = await generateObject({ // [!code highlight] - model: openai('gpt-5-mini'), - system: 'You are a product consultant. Provide a brief 2-3 sentence recommendation based on the search results. Reference customer feedback when relevant.', - prompt: `Customer searched for: "${input.query}"\n\nMatching products:\n${context}`, - schema: z.object({ - summary: z.string().describe('Brief recommendation explaining the best choice'), - recommendedSKU: z.string().describe('SKU of the recommended product'), - }), - }); +export async function recommendProduct(body: unknown): Promise { + const { products } = await searchProducts(body); + if (products.length === 0) { return { - matches, - recommendation: object.summary, - recommendedSKU: object.recommendedSKU, + matches: [], + recommendedId: '', + rationale: 'No matching products found. Try a broader search.', }; - }, -}); + } + + const catalog = products + .map( + (p) => + `- id=${p.id} | ${p.name} | $${p.price} | rating=${p.rating} | ${p.description}` + ) + .join('\n'); + + const { output } = await generateText({ + model: openai('gpt-5.5'), + output: Output.object({ schema: recommendationSchema }), + system: 'You help customers compare products. Pick exactly one option from the list.', + prompt: `Candidates:\n${catalog}\n\nPick the option that fits a typical customer searching this catalog.`, + }); -export default agent; + return { + matches: products, + recommendedId: output.recommendedId, + rationale: output.rationale, + }; +} ``` - -Add AI recommendations when customers benefit from personalized guidance: comparing similar products, explaining trade-offs, or highlighting relevant features based on their search intent. - +## Wire the Routes -### Example Response +The same product search powers two routes: a plain GET for query-string consumers and a POST that returns a recommendation alongside matches. -```json -{ - "matches": [ - { "sku": "CHAIR-ERG-001", "name": "ErgoMax Pro", "price": 549, "rating": 4.8, "similarity": 0.89 }, - { "sku": "CHAIR-BUD-002", "name": "ComfortBasic", "price": 129, "rating": 4.2, "similarity": 0.76 } - ], - "recommendation": "For a comfortable office chair, I recommend the ErgoMax Pro. Customers report significant back pain relief, and the premium lumbar support justifies the higher price for long work sessions.", - "recommendedSKU": "CHAIR-ERG-001" -} -``` +```typescript title="src/index.ts" +import { Hono } from 'hono'; +import { indexProducts } from './lib/index-products'; +import { searchProducts } from './lib/search-products'; +import { recommendProduct } from './lib/recommend-product'; -## Frontend +const app = new Hono(); -A search interface that displays matches and the AI recommendation: +app.post('/api/products/index', async (c) => { + const body: unknown = await c.req.json(); + const result = await indexProducts(body); + return c.json(result); +}); -```tsx title="src/web/App.tsx" -import { useState } from 'react'; +app.get('/api/products/search', async (c) => { + const result = await searchProducts({ + query: c.req.query('q') ?? '', + category: c.req.query('category'), + maxPrice: parseOptionalNumber(c.req.query('maxPrice')), + limit: parseOptionalNumber(c.req.query('limit')), + }); + return c.json(result); +}); -interface Match { - sku: string; - name: string; - price: number; - rating: number; - similarity: number; -} +app.post('/api/products/recommend', async (c) => { + const body: unknown = await c.req.json(); + const result = await recommendProduct(body); + return c.json(result); +}); -interface SearchResult { - matches: Match[]; - recommendation: string; - recommendedSKU: string; +function parseOptionalNumber(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; } -export function App() { - const [query, setQuery] = useState(''); - const [result, setResult] = useState(null); - const [loading, setLoading] = useState(false); +export default app; +``` - const search = async () => { - if (!query.trim()) return; - setLoading(true); +## Try It - const response = await fetch('/api/products/advisor', { // [!code highlight] - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), - }); - const data = await response.json(); - setResult(data); - setLoading(false); - }; +```bash +agentuity dev +``` - return ( -
-

Product Search

- -
- setQuery(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && search()} - placeholder="Search products..." - style={{ flex: 1, padding: '0.75rem' }} - /> - -
- - {result && ( - <> - {/* AI Recommendation */} - {result.recommendation && ( -
- AI Recommendation -

{result.recommendation}

-
- )} - - {/* Matches */} -
- {result.matches.map((match) => ( -
-
-
- {match.name} - {match.sku === result.recommendedSKU && ( - - Recommended - - )} -
{match.sku}
-
-
-
${match.price}
-
- {match.rating} stars · {Math.round(match.similarity * 100)}% match -
-
-
-
- ))} -
- - )} -
- ); -} +Index a small catalog: + +```bash +curl -X POST http://localhost:3500/api/products/index \ + -H 'content-type: application/json' \ + -d '{ + "products": [ + { + "id": "chair-ergo-001", + "name": "ErgoMax Pro", + "description": "Office chair with adjustable lumbar support and 4D armrests.", + "price": 549, + "category": "furniture", + "rating": 4.8 + }, + { + "id": "chair-basic-002", + "name": "ComfortBasic", + "description": "Affordable mesh task chair with fixed armrests.", + "price": 129, + "category": "furniture", + "rating": 4.2 + } + ] + }' ``` -## Testing +Search by query string: + +```bash +curl 'http://localhost:3500/api/products/search?q=comfortable%20office%20chair&maxPrice=300' +``` -Use the frontend to search interactively. If you need to call the endpoint directly: +Get a model recommendation: ```bash -curl "http://localhost:3500/api/products/search?q=comfortable%20office%20chair" -curl "http://localhost:3500/api/products/search?q=laptop&category=electronics&maxPrice=1000" -curl -X POST "http://localhost:3500/api/products/advisor" \ - -H "Content-Type: application/json" \ - -d '{"query":"comfortable office chair"}' +curl -X POST http://localhost:3500/api/products/recommend \ + -H 'content-type: application/json' \ + -d '{ "query": "supportive chair for a long workday" }' ``` -## Key Points +## Notes -- **Semantic search** finds products by meaning, not just keywords -- **Metadata filtering** narrows results by category, price, stock -- **AI recommendations** add personalized guidance based on search context -- **Customer feedback** in metadata helps AI make relevant suggestions -- **Document field** should include searchable text (name + description) +- store searchable text in `document`, structured fields in `metadata` +- post-filter for booleans and numeric ranges; pass `metadata` to `search()` only when the filter satisfies the full metadata type +- pull `limit * 2` matches when post-filtering reduces the result set +- treat `vector.upsert()` as idempotent; reindex the same `id` to replace it +- AI SDK's `Output.object` validates the model response before the route returns -## See Also +## Next Steps -- [Vector Storage](/services/storage/vector) for all vector operations -- [Build a RAG Agent](/cookbook/tutorials/rag-agent) for question-answering -- [AI SDK Integration](/agents/ai-sdk-integration) for `generateObject` patterns +- [Vector Storage](/services/storage/vector): metadata generics, TTL behavior, and search options +- [Build a RAG Agent](/cookbook/tutorials/rag-agent): retrieval plus a generated answer with citations +- [Build Agents](/build/agents): the plain-function pattern this page uses diff --git a/docs/src/web/content/cookbook/patterns/server-utilities.mdx b/docs/src/web/content/cookbook/patterns/server-utilities.mdx index afc6a1c90..95b71b42b 100644 --- a/docs/src/web/content/cookbook/patterns/server-utilities.mdx +++ b/docs/src/web/content/cookbook/patterns/server-utilities.mdx @@ -331,16 +331,24 @@ if (message) { For one-off queue management, use the CLI instead: `agentuity cloud queue create worker --name my-queue`, `agentuity cloud queue dlq list my-queue`, etc. See [Queues](/services/queues) for CLI commands.
-## Alternative: HTTP Routes +## Alternative: Hono Routes -If you want to centralize storage logic in your Agentuity project (for [middleware](/routes/middleware), sharing across multiple apps, or avoiding SDK key distribution), use [HTTP routes](/routes/http) instead. +If you want to centralize storage logic in a Hono app, use the Agentuity middleware and keep service access behind routes. + +```bash +bun add @agentuity/hono@alpha hono +``` ```typescript title="src/api/sessions/route.ts" import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; +import { agentuity } from '@agentuity/hono'; +import type { Services } from '@agentuity/hono'; import { s } from '@agentuity/schema'; -const router = new Hono(); +type Variables = Pick; + +const router = new Hono<{ Variables: Variables }>(); +router.use('*', agentuity()); const MessageSchema = s.object({ role: s.string(), @@ -355,12 +363,11 @@ type Message = s.infer; type SessionPatch = s.infer; interface ChatSession { - messages: Message[]; - createdAt: string; - updatedAt: string; + readonly messages: readonly Message[]; + readonly createdAt: string; + readonly updatedAt: string; } -// Get a session by ID router.get('/:id', async (c) => { const sessionId = c.req.param('id'); const result = await c.var.kv.get('sessions', sessionId); // [!code highlight] @@ -372,7 +379,6 @@ router.get('/:id', async (c) => { return c.json(result.data); }); -// Create or update a session router.post('/:id', async (c) => { const sessionId = c.req.param('id'); const payload = await c.req.json(); @@ -393,7 +399,6 @@ router.post('/:id', async (c) => { return c.json(session); }); -// Delete a session router.delete('/:id', async (c) => { const sessionId = c.req.param('id'); await c.var.kv.delete('sessions', sessionId); // [!code highlight] @@ -411,12 +416,16 @@ Add authentication middleware to protect storage endpoints: ```typescript title="src/api/sessions/route.ts" import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; +import { agentuity } from '@agentuity/hono'; +import type { Services } from '@agentuity/hono'; import { createMiddleware } from 'hono/factory'; -const router = new Hono(); +type Variables = Pick; + +const router = new Hono<{ Variables: Variables }>(); +router.use('*', agentuity()); -const requireAuth = createMiddleware(async (c, next) => { +const requireAuth = createMiddleware<{ Variables: Variables }>(async (c, next) => { const apiKey = c.req.header('x-api-key'); if (!apiKey || apiKey !== process.env.STORAGE_API_KEY) { @@ -565,6 +574,5 @@ const jsonSchema = toJSONSchema(schema); - [Standalone Packages](/reference/standalone-packages): Full list of service packages and their APIs - [Queues](/services/queues): Queue concepts and CLI commands -- [HTTP Routes](/routes/http): Route creation with Hono -- [Route Middleware](/routes/middleware): Authentication patterns -- [RPC Client](/frontend/rpc-client): Typed client generation +- [Using Hono with Agentuity](/frameworks/hono): Route creation and middleware patterns +- [Hono RPC + TanStack](/cookbook/patterns/hono-rpc-tanstack-query): Share Hono route types with a React client diff --git a/docs/src/web/content/cookbook/patterns/tailwind-setup.mdx b/docs/src/web/content/cookbook/patterns/tailwind-setup.mdx index bd8ef3c0a..cf8e4cfc8 100644 --- a/docs/src/web/content/cookbook/patterns/tailwind-setup.mdx +++ b/docs/src/web/content/cookbook/patterns/tailwind-setup.mdx @@ -1,106 +1,55 @@ --- title: Tailwind CSS Setup short_title: Tailwind Setup -description: Add Tailwind CSS styling to your Agentuity frontend +description: Add Tailwind CSS to the framework app you deploy with Agentuity --- -Add [Tailwind CSS](https://tailwindcss.com) v4 to your Agentuity project using the official Tailwind Vite plugin. +Agentuity v3 does not require a special Tailwind setup. Use the Tailwind path your framework expects, then keep Agentuity service clients in server routes. - -The default template includes Tailwind CSS pre-configured: +## Vite-Based Apps -```bash -agentuity create my-app -``` - - -## Prerequisites - -- An Agentuity project with a `src/web/` directory -- Bun runtime (included with Agentuity CLI) - -## Step 1: Install Dependencies +For Vite-based apps, use Tailwind's Vite plugin: ```bash bun add -D tailwindcss @tailwindcss/vite ``` -## Step 2: Create Build Configuration - -Add the Tailwind plugin to your existing `vite.config.ts`. Keep the React plugin and any Agentuity build settings already in the file: - ```typescript title="vite.config.ts" -import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vite'; -import { join } from 'node:path'; export default defineConfig({ - plugins: [react(), tailwindcss()], - root: '.', - publicDir: 'src/web/public', - build: { - rollupOptions: { - input: join(import.meta.dirname, 'src/web/index.html'), - }, - }, + plugins: [tailwindcss()], }); ``` -## Step 3: Import Tailwind in Your CSS +Import Tailwind from the CSS file your framework already loads: -Create or update the CSS file imported by your frontend, for example `src/web/App.css`: - -```css +```css title="src/styles.css" @import 'tailwindcss'; ``` -Then import it from your frontend entry or root component: - -```typescript title="src/web/App.tsx" -import './App.css'; -``` - -## Step 4: Use Tailwind Classes - -Add utility classes to your React components: +Then use Tailwind classes in your components: ```tsx -// src/web/App.tsx -export function App() { +export function EmptyState() { return ( -
-
-

- Hello, Tailwind! -

-

- Your Agentuity frontend is styled with Tailwind CSS. -

-
-
+
+

No sessions yet

+

+ Start a Coder session or run an API route to create your first record. +

+
); } ``` -## Step 5: Verify - -Start the dev server and check that styles are applied: - -```bash -bun run dev -``` - -Open `http://localhost:3500` in your browser. You should see your styled component. - -## Key Points +## Framework Apps -- Tailwind applies to frontend builds only (via Vite) -- The plugin scans your components and generates only the CSS you use -- No `tailwind.config.ts` needed for basic usage (Tailwind v4 works out of the box) -- Style changes reflect immediately in dev mode via Vite HMR +If your app uses Next.js, TanStack Start, SvelteKit, Astro, Nuxt, or React Router, follow that framework's Tailwind guide. Agentuity deploys the built framework output and does not need its own Tailwind config. ## Next Steps -- [Build Configuration](/reference/cli/build-configuration): Explore all config options including plugins and define constants -- [Frontend Development](/frontend/react-hooks): Connect your styled frontend to agents using React hooks +- [Frameworks](/frameworks): choose the framework page that matches your app +- [Using Next.js with Agentuity](/frameworks/nextjs): add Agentuity clients to Next.js route handlers +- [Using TanStack Start with Agentuity](/frameworks/tanstack-start): use Agentuity clients from TanStack server routes diff --git a/docs/src/web/content/cookbook/patterns/web-exploration.mdx b/docs/src/web/content/cookbook/patterns/web-exploration.mdx index 056058e38..c8a24048d 100644 --- a/docs/src/web/content/cookbook/patterns/web-exploration.mdx +++ b/docs/src/web/content/cookbook/patterns/web-exploration.mdx @@ -1,272 +1,346 @@ --- title: Web Exploration with Sandboxes short_title: Web Exploration -description: Run a headless browser in a sandbox to let agents browse, screenshot, and extract web content +description: Run a headless browser inside a sandbox so an agent can navigate, screenshot, and extract content under isolation --- -Agents sometimes need to interact with live websites: take screenshots, click buttons, fill forms, and extract content. [Sandboxes](/services/sandbox/sdk-usage) provide isolated browser environments for this, keeping your agent's host clean and secure. +Agents that interact with live websites belong in a sandbox. Each session gets isolated network egress, ephemeral disk, and a clean process tree, so the agent can run untrusted page interactions without touching the host. This pattern uses [`SandboxClient`](/services/sandbox/sdk-usage), an AI SDK tool loop, and key-value memory. -## The Pattern +```bash +bun add hono ai @ai-sdk/openai @agentuity/sandbox@alpha @agentuity/keyvalue@alpha @agentuity/schema@alpha zod +``` -Create a sandbox from a browser-ready snapshot, then use AI SDK tool calling to let the model decide what to do on each page. The agent takes screenshots to observe the page, interacts with element refs, and stores findings in [KV storage](/services/storage/key-value) for memory across sessions. +## Prerequisite: a Browser-Ready Snapshot -```typescript title="src/lib/explorer.ts" -import { generateText, tool, hasToolCall, stepCountIs } from 'ai'; -import { openai } from '@ai-sdk/openai'; -import { z } from 'zod'; -import type { Sandbox } from '@agentuity/core'; -``` +`SandboxClient.create({ snapshot })` accepts a snapshot ID or tag. Build a snapshot that includes a headless browser (Playwright with Chromium is the common choice) plus a small CLI wrapper that exposes the actions you want the model to call: `screenshot`, `navigate`, `click`, `fill`, `eval`. Save it once, then create sandboxes from the tag. -## Creating the Sandbox +```typescript title="scripts/build-snapshot.ts" +import { readFile } from 'node:fs/promises'; +import { SandboxClient } from '@agentuity/sandbox'; +import { logger } from '@agentuity/telemetry'; -Use a sandbox snapshot that already includes `agent-browser` and Chromium. Build the snapshot from an available runtime, tag it, then create sandboxes from that tag. See [Sandbox Snapshots](/services/sandbox/snapshots) for the snapshot workflow. +const sandboxes = new SandboxClient(); +const browserCli = await readFile(new URL('./browser-cli.ts', import.meta.url)); -```typescript title="src/lib/explorer.ts" -const sandbox = await ctx.sandbox.create({ - snapshot: 'browser-tools', - network: { enabled: true }, // [!code highlight] - resources: { memory: '1Gi', cpu: '1000m' }, - timeout: { idle: '10m', execution: '30s' }, +const sandbox = await sandboxes.create({ + runtime: 'bun:1', + resources: { memory: '2Gi', cpu: '1000m' }, + timeout: { execution: '5m' }, + files: [{ path: 'browser-cli.ts', content: browserCli }], }); + +try { + await sandbox.execute({ + command: [ + 'sh', + '-c', + 'bun add playwright && bunx playwright install --with-deps chromium && cp browser-cli.ts /usr/local/bin/browser-cli && chmod +x /usr/local/bin/browser-cli', + ], + }); + + const snapshot = await sandboxes.createSnapshot(sandbox.id, { name: 'browser-tools' }); + logger.info('snapshot created', { id: snapshot.snapshotId }); +} finally { + await sandbox.destroy(); +} ``` -The `execution` timeout applies per command (each screenshot or click), while `idle` controls how long the sandbox stays alive between commands. This lets the agent take its time planning without the sandbox disappearing mid-session. +See [Sandbox Snapshots](/services/sandbox/snapshots) for the full snapshot workflow. The rest of this page assumes a snapshot tagged `browser-tools` exists. + + +The `browser-cli.ts` wrapper is the boundary between the model and the browser. Keep it app-specific and checked in with the snapshot build script; the example above expects that file next to `build-snapshot.ts` and writes it into the sandbox before installing Playwright. + -## Defining Browser Tools +## Define the Browser Tools -The model needs tools to interact with the page. A single `browser` tool handles all actions through a dispatch pattern, while `store_finding` and `finish_exploration` control the research loop. +Tools dispatch to commands inside the sandbox. Keep the tool surface small: one tool for browser actions, one for storing findings, one to finish the loop. ```typescript title="src/lib/explorer.ts" -const tools = { - browser: tool({ - description: 'Control the sandbox browser. Use screenshot to see the page and get element refs.', - inputSchema: z.object({ - action: z.enum([ - 'screenshot', 'click', 'fill', 'scroll', - 'navigate', 'back', 'press', 'hover', 'eval', 'wait', - ]), - ref: z.string().nullable() - .describe('Element ref like @e5. Required for: click, fill, hover'), - value: z.string().nullable() - .describe('Text for fill, key for press, URL for navigate, JS for eval'), - direction: z.enum(['up', 'down']).nullable() - .describe('Scroll direction. Required for: scroll'), - reason: z.string() - .describe('Why this action'), - }), - strict: true, - execute: async ({ action, ref, value, direction, reason }) => { - if (action === 'screenshot') { - return handleScreenshot(sandbox, ctx); - } +import { Writable } from 'node:stream'; +import { SandboxClient } from '@agentuity/sandbox'; +import type { SandboxInstance } from '@agentuity/sandbox'; +import { KeyValueClient } from '@agentuity/keyvalue'; +import { tool } from 'ai'; +import { z } from 'zod'; + +const KV_NAMESPACE = 'web-explorer'; +const VISIT_TTL_SECONDS = 60 * 60 * 24; +const DOMAIN_INDEX_TTL_SECONDS = 60 * 60 * 24 * 7; + +interface VisitRecord { + readonly url: string; + readonly title: string; + readonly observation: string; + readonly visitedAt: string; +} + +interface ExplorerEnv { + readonly sandbox: SandboxInstance; + readonly kv: KeyValueClient; + readonly url: string; +} - // Dispatch to the right browser command - const handler = BROWSER_DISPATCH[action]; - if (!handler) return `Unknown action: ${action}`; - return handler.run(sandbox, ref, value, direction); +interface CaptureResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +// `sandbox.execute()` returns stream URLs, not inline output, so we pipe both +// streams into in-memory buffers before returning to the caller +async function executeCapture( + sandbox: SandboxInstance, + command: readonly string[] +): Promise { + let stdout = ''; + let stderr = ''; + + const stdoutWritable = new Writable({ + write(chunk, _encoding, callback) { + stdout += chunk.toString('utf8'); + callback(); }, - }), + }); - store_finding: tool({ - description: 'Save what you learned about the current feature, then move to a new section.', - inputSchema: z.object({ - title: z.string().describe('Short title for this finding'), - observation: z.string().describe('What you discovered'), - }), - strict: true, - execute: async ({ title, observation }) => { - await storeVisit(ctx, { url: currentUrl, title, observation }); - return `Stored: ${title}. Navigate to a new section or call finish_exploration.`; + const stderrWritable = new Writable({ + write(chunk, _encoding, callback) { + stderr += chunk.toString('utf8'); + callback(); }, - }), + }); + + const result = await sandbox.execute({ + command: [...command], + pipe: { stdout: stdoutWritable, stderr: stderrWritable }, + }); + + return { + exitCode: result.exitCode ?? -1, + stdout, + stderr, + }; +} - finish_exploration: tool({ - description: 'End the exploration and deliver your summary.', - inputSchema: z.object({ - summary: z.string().describe('2-3 sentence summary of your exploration'), +function buildTools(env: ExplorerEnv) { + return { + browser: tool({ + description: + 'Drive the headless browser. Use screenshot to inspect the page, then click or fill by element ref.', + inputSchema: z.object({ + action: z.enum(['screenshot', 'click', 'fill', 'navigate', 'back', 'eval']), + ref: z.string().nullable().describe('Element ref like @e5. Required for click and fill.'), + value: z.string().nullable().describe('Text for fill, URL for navigate, JS for eval.'), + reason: z.string().describe('Why this action.'), + }), + execute: async ({ action, ref, value }) => { + const args: string[] = ['browser-cli', action]; + if (ref) args.push('--ref', ref); + if (value) args.push('--value', value); + + const result = await executeCapture(env.sandbox, args); + + if (result.exitCode !== 0) { + return `Browser command failed: ${result.stderr.slice(0, 500)}`; + } + return result.stdout.slice(0, 4000); + }, }), - strict: true, - // No execute, stops the loop via hasToolCall() - }), -}; -``` - -The `finish_exploration` tool has no `execute` function. It acts as a structured output mechanism: when the model calls it, `hasToolCall('finish_exploration')` stops the `generateText` loop and the summary is extracted from the tool call input. -## Running the Exploration Loop + store_finding: tool({ + description: 'Save what you learned about this section, then move on.', + inputSchema: z.object({ + title: z.string(), + observation: z.string(), + }), + execute: async ({ title, observation }) => { + const visit: VisitRecord = { + url: env.url, + title, + observation, + visitedAt: new Date().toISOString(), + }; + + await env.kv.set(KV_NAMESPACE, `visit:${normalizeUrl(env.url)}`, visit, { + ttl: VISIT_TTL_SECONDS, + }); + + await indexDomain(env.kv, env.url); + return `Stored "${title}". Move to a new section or call finish_exploration.`; + }, + }), -With the tools defined, the exploration is a single `generateText` call. Stop when the model calls `finish_exploration`, or when the step limit is reached. + finish_exploration: tool({ + description: 'End the exploration with a 2-3 sentence summary.', + inputSchema: z.object({ summary: z.string() }), + // No execute: hasToolCall('finish_exploration') stops the loop + }), + }; +} -```typescript title="src/lib/explorer.ts" -const maxSteps = options.maxSteps ?? 12; - -const result = await generateText({ - model: openai('gpt-5.4-nano'), - system: buildSystemPrompt(url), - prompt: `Begin exploring ${url}. Take a screenshot first, then interact with features.`, - tools, - stopWhen: [hasToolCall('finish_exploration'), stepCountIs(maxSteps)], // [!code highlight] -}); +function normalizeUrl(input: string): string { + const url = new URL(input); + return `${url.origin}${url.pathname}`; +} -let summary = result.text || ''; +async function indexDomain(kv: KeyValueClient, rawUrl: string): Promise { + const domain = new URL(rawUrl).hostname; + const normalized = normalizeUrl(rawUrl); + const indexKey = `domain:${domain}`; -for (const step of result.steps) { - for (const toolCall of step.toolCalls) { - if (toolCall.toolName !== 'finish_exploration') continue; + const existing = await kv.get(KV_NAMESPACE, indexKey); + const urls = existing.exists ? [...existing.data] : []; - const input = toolCall.input; - if ( - typeof input === 'object' && - input !== null && - 'summary' in input && - typeof input.summary === 'string' - ) { - summary = input.summary; // [!code highlight] - } + if (!urls.includes(normalized)) { + urls.push(normalized); + await kv.set(KV_NAMESPACE, indexKey, urls, { ttl: DOMAIN_INDEX_TTL_SECONDS }); } } ``` -## Executing Browser Commands - -Each browser action runs a command inside the sandbox using `sandbox.execute()`. Screenshots are captured as images, encoded to base64, and uploaded to [object storage](/services/storage/object) for durable URLs. +`finish_exploration` has no `execute`. Calling it short-circuits the AI SDK tool loop via `hasToolCall('finish_exploration')`, and the call's `input.summary` becomes the result. -```typescript title="src/lib/explorer.ts" -async function handleScreenshot(sandbox: Sandbox, ctx: ExplorerContext): Promise { - const filename = `step-${Date.now()}.png`; - - // Capture screenshot inside the sandbox - await sandbox.execute({ command: ['agent-browser', 'screenshot', filename] }); +## Run the Loop - // Read the screenshot file as base64 - const b64Exec = await sandbox.execute({ command: ['base64', filename] }); - const base64 = (await readStdout(b64Exec)).trim(); - const buffer = Buffer.from(base64, 'base64'); +The exploration is one `generateText` call with three tools and two stop conditions: a successful finish, or a step cap. - // Upload to object storage for a durable URL - const screenshotUrl = await uploadScreenshot(`screenshots/${filename}`, buffer); +```typescript title="src/lib/explorer.ts" +import { generateText, hasToolCall, stepCountIs } from 'ai'; +import { openai } from '@ai-sdk/openai'; - // Get accessibility tree for element refs like @e1 and @e2 - const snapshotExec = await sandbox.execute({ command: ['agent-browser', 'snapshot', '-i'] }); - const elements = await readStdout(snapshotExec); +interface ExplorationResult { + readonly summary: string; + readonly findings: readonly VisitRecord[]; +} - return `Screenshot captured. URL: ${screenshotUrl}\n\nInteractive elements:\n${elements}`; +interface ExploreOptions { + readonly url: string; + readonly maxSteps?: number; } -``` -The accessibility tree returned by `agent-browser snapshot -i` contains element refs like `@e1`, `@e5` that the model uses in subsequent `click` or `fill` actions. +const sandboxes = new SandboxClient(); +const kv = new KeyValueClient(); + +export async function explore(options: ExploreOptions): Promise { + const sandbox = await sandboxes.create({ + snapshot: 'browser-tools', + network: { enabled: true }, + resources: { memory: '1Gi', cpu: '1000m' }, + timeout: { idle: '10m', execution: '30s' }, + }); + + try { + const past = await loadPastVisits(kv, options.url); + const memoryContext = past + .map((visit) => `- ${visit.url}: ${visit.observation}`) + .join('\n'); + + const prompt = past.length > 0 + ? `Begin exploring ${options.url}. Take a screenshot first.\n\nAlready explored (do not revisit):\n${memoryContext}` + : `Begin exploring ${options.url}. Take a screenshot first, then interact.`; + + const tools = buildTools({ sandbox, kv, url: options.url }); + + const result = await generateText({ + model: openai('gpt-5.4'), + system: SYSTEM_PROMPT, + prompt, + tools, + stopWhen: [hasToolCall('finish_exploration'), stepCountIs(options.maxSteps ?? 12)], + }); + + let summary = result.text; + for (const step of result.steps) { + for (const call of step.toolCalls) { + if (call.toolName !== 'finish_exploration') continue; + const input = call.input; + const finishedSummary = getFinishSummary(input); + if (finishedSummary) summary = finishedSummary; + } + } -## KV Memory for Visit History + return { + summary, + findings: await loadPastVisits(kv, options.url), + }; + } finally { + await sandbox.destroy(); + } +} -Storing visits in KV lets the agent remember what it has already explored. On repeat visits to the same domain, past findings are loaded and injected into the prompt so the model focuses on new areas. +const SYSTEM_PROMPT = `You explore a web page on behalf of a user. -```typescript title="src/lib/explorer.ts" -const KV_NAMESPACE = 'web-explorer'; +Loop: +1. Screenshot the current page. +2. Identify interactive elements by ref (@e1, @e5, ...). +3. Click or fill to investigate one feature at a time. +4. Call store_finding when you have learned something concrete. +5. Call finish_exploration when you have explored 2-4 features.`; -async function storeVisit( - ctx: ExplorerContext, - params: { url: string; title: string; observation: string } -): Promise { - const normalized = normalizeUrl(params.url); - - // Store visit record with 24h TTL - await ctx.kv.set(KV_NAMESPACE, `visit:${normalized}`, { // [!code highlight] - ...params, - visitedAt: new Date().toISOString(), - }, { ttl: 86400 }); // [!code highlight] - - // Update domain index so future visits can load domain memory - const domain = new URL(params.url).hostname; - const indexResult = await ctx.kv.get(KV_NAMESPACE, `domain:${domain}`); - const urls = indexResult.exists ? indexResult.data : []; - if (!urls.includes(normalized)) { - urls.push(normalized); - await ctx.kv.set(KV_NAMESPACE, `domain:${domain}`, urls, { ttl: 86400 * 7 }); - } +function getFinishSummary(input: unknown): string | undefined { + if (typeof input !== 'object' || input === null) return undefined; + if (!('summary' in input) || typeof input.summary !== 'string') return undefined; + return input.summary; } -async function loadPastVisits(ctx: ExplorerContext, url: string): Promise { - const domain = new URL(url).hostname; - const indexResult = await ctx.kv.get(KV_NAMESPACE, `domain:${domain}`); - if (!indexResult.exists) return []; - - const visits: MemoryVisit[] = []; - for (const normalizedUrl of indexResult.data) { - const result = await ctx.kv.get(KV_NAMESPACE, `visit:${normalizedUrl}`); - if (result.exists && result.data.observation) { - visits.push(result.data); - } +async function loadPastVisits( + kv: KeyValueClient, + rawUrl: string +): Promise { + const domain = new URL(rawUrl).hostname; + const index = await kv.get(KV_NAMESPACE, `domain:${domain}`); + if (!index.exists) return []; + + const visits: VisitRecord[] = []; + for (const normalized of index.data) { + const visit = await kv.get(KV_NAMESPACE, `visit:${normalized}`); + if (visit.exists) visits.push(visit.data); } return visits; } ``` -When past visits exist, they are formatted and added to the model's prompt: - -```typescript -const pastVisits = await loadPastVisits(ctx, url); -const memoryContext = pastVisits - .map((v) => `- ${v.url}: ${v.observation}`) - .join('\n'); - -const prompt = memoryContext - ? `Begin exploring ${url}.\n\nAlready explored (do not revisit):\n${memoryContext}` - : `Begin exploring ${url}. Take a screenshot first.`; -``` +The sandbox is destroyed in `finally` so a thrown error inside the loop still cleans up. `network: { enabled: true }` is required because the sandbox default disables egress. -## Cleanup +## Wire the Route -Always destroy the sandbox when finished, even if the exploration fails. +The route is a thin call through to `explore()`. The interesting code lives in the library module, so it is also reachable from a queue worker or a script. -```typescript title="src/agent/web-explorer/agent.ts" -import { createAgent } from '@agentuity/runtime'; +```typescript title="src/index.ts" +import { Hono } from 'hono'; import { s } from '@agentuity/schema'; -import { explore } from '@lib/explorer'; +import { explore } from './lib/explorer'; -const AgentInput = s.object({ +const requestSchema = s.object({ url: s.string(), maxSteps: s.optional(s.number()), }); -const AgentOutput = s.object({ - summary: s.string(), - findings: s.array(s.string()), -}); +const app = new Hono(); -const agent = createAgent('web-explorer', { - description: 'Explores a URL in a headless browser sandbox with AI-guided actions', - schema: { - input: AgentInput, - output: AgentOutput, - }, - handler: async (ctx, input) => { - return explore( - { logger: ctx.logger, kv: ctx.kv, sandbox: ctx.sandbox }, - { url: input.url, maxSteps: input.maxSteps }, - ); - }, +app.post('/api/explore', async (c) => { + const body: unknown = await c.req.json(); + const input = requestSchema.parse(body); + const result = await explore(input); + return c.json(result); }); -export default agent; +export default app; ``` -Inside `explore()`, the sandbox is destroyed in a `finally` block so it is always cleaned up: +## Notes -```typescript -try { - return await exploreWithSandbox(ctx, sandbox, options); -} finally { - await sandbox.destroy(); // [!code highlight] -} -``` - - -See the [Web Explorer](https://github.com/agentuity/examples/tree/main/features/web-explorer) example for the complete implementation with SSE streaming, session resume, S3 screenshot uploads, and a React timeline UI. - +- snapshots make sandbox starts fast; building the browser at create time adds tens of seconds per request +- `sandbox.execute()` returns stream URLs, not inline output; pipe `stdout`/`stderr` into Node `Writable`s when you need the text +- `execute()` reuses one server connection per call, so chaining many short commands is fine +- screenshots round-trip a lot of bytes; encode and stream to object storage if the agent needs to share images +- key-value memory keeps past observations cheap; load them into the prompt so the model does not redo work +- `network: { enabled: true }` is opt-in; sandbox traffic is otherwise blocked +- raise `timeout.execution` when one browser action may spend longer than 30 seconds on a slow page +- always destroy the sandbox in a `finally` block; an idle sandbox keeps billing until the idle timeout fires ## Next Steps -- [Sandbox SDK Usage](/services/sandbox/sdk-usage): Full sandbox API for creating, executing, and managing sandboxes -- [Snapshots](/services/sandbox/snapshots): Pre-warm sandboxes with dependencies for faster cold starts -- [Key-Value Storage](/services/storage/key-value): KV operations, TTL, and namespace patterns -- [Object Storage](/services/storage/object): Upload and serve files with presigned URLs +- [Sandbox SDK Usage](/services/sandbox/sdk-usage): full lifecycle for `SandboxClient` and `SandboxInstance` +- [Sandbox Snapshots](/services/sandbox/snapshots): build, tag, and reuse snapshots +- [Key-Value Storage](/services/storage/key-value): TTLs, namespace patterns, search +- [Object Storage](/services/storage/object): persist screenshots with shareable URLs diff --git a/docs/src/web/content/cookbook/patterns/webhook-handler.mdx b/docs/src/web/content/cookbook/patterns/webhook-handler.mdx index ab162dc8a..27d4d4783 100644 --- a/docs/src/web/content/cookbook/patterns/webhook-handler.mdx +++ b/docs/src/web/content/cookbook/patterns/webhook-handler.mdx @@ -1,85 +1,115 @@ --- title: Webhook Handler -description: Handle incoming webhooks with signature verification and background processing +short_title: Webhook Handler +description: Verify a signed external webhook in a framework route and hand the payload to a queue --- -Process webhooks from external services (Stripe, GitHub, Slack) with proper signature verification and fast response times. +External services like Stripe, GitHub, and Slack need a 2xx response in seconds and retry on anything else. The pattern is: read the raw body, verify the signature, publish to a queue, return 2xx. The slow work runs in a separate worker route triggered by the queue's HTTP destination. -## The Pattern +```bash +bun add hono stripe @agentuity/queue@alpha @agentuity/keyvalue@alpha @agentuity/schema@alpha +``` + + +Use [`WebhookClient`](/services/webhooks) for a hosted ingest URL with receipts and delivery retries. The patterns below are for external webhooks where your app owns the verification and dispatch path. + -Webhooks require quick responses, usually under 3 seconds. Verify the raw request body first, then use `waitUntil` to acknowledge immediately and process in the background. +## Stripe: Verify and Hand Off -For Stripe, use the official Node SDK to verify the `stripe-signature` header against the raw body: +Stripe's SDK verifies the signature against the raw request body. Once the event is verified, push it onto a queue and return immediately. -```typescript title="src/api/webhooks/route.ts" +```typescript title="src/index.ts" import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; import Stripe from 'stripe'; -import stripeEventHandler from '@agent/stripe-event-handler/agent'; +import { QueueClient } from '@agentuity/queue'; +import { KeyValueClient } from '@agentuity/keyvalue'; -const router = new Hono(); +const STRIPE_QUEUE = 'stripe-events'; +const FAILED_NAMESPACE = 'failed-webhooks'; -router.post('/stripe', async (c) => { - const rawBody = await c.req.text(); - const signature = c.req.header('stripe-signature'); +const queue = new QueueClient(); +const kv = new KeyValueClient(); + +const app = new Hono(); + +app.post('/api/webhooks/stripe', async (c) => { const apiKey = process.env.STRIPE_SECRET_KEY; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + const signature = c.req.header('stripe-signature'); + + if (!apiKey || !webhookSecret) { + return c.json({ error: 'Stripe webhook is not configured' }, 500); + } - if (!apiKey || !webhookSecret || !signature) { - c.var.logger.error('Stripe webhook is missing configuration'); - return c.text('Webhook not configured', 500); + if (!signature) { + return c.json({ error: 'Missing stripe-signature header' }, 400); } + // Stripe's SDK verifies the HMAC against the raw body + const rawBody = await c.req.text(); const stripe = new Stripe(apiKey); - let event: Stripe.Event; + let event: Stripe.Event; try { - event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); // [!code highlight] + event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); } catch (error) { - c.var.logger.warn('Invalid Stripe webhook signature', { error }); - return c.text('Invalid signature', 400); + const message = error instanceof Error ? error.message : 'Invalid signature'; + return c.json({ error: message }, 400); } - c.var.logger.info('Webhook received', { type: event.type }); - - c.waitUntil(async () => { // [!code highlight] - try { - await stripeEventHandler.run({ - eventId: event.id, + try { + await queue.publish(STRIPE_QUEUE, event, { + // Stripe retries on non-2xx; idempotency stops duplicate processing + idempotencyKey: event.id, + partitionKey: typeof event.data.object === 'object' && event.data.object && 'customer' in event.data.object + ? String(event.data.object.customer ?? event.id) + : event.id, + }); + } catch (error) { + // Persist enough to replay the event by hand if the queue is unavailable + await kv.set( + FAILED_NAMESPACE, + event.id, + { type: event.type, - payload: event.data.object, - }); - } catch (error) { - c.var.logger.error('Webhook processing failed', { error, eventType: event.type }); - - await c.var.kv.set('failed-webhooks', event.id, { - event, - error: error instanceof Error ? error.message : 'Unknown error', - timestamp: Date.now(), - }, { ttl: 86400 }); // 24 hours - } - }); + receivedAt: new Date().toISOString(), + message: error instanceof Error ? error.message : 'unknown error', + }, + { ttl: 60 * 60 * 24 } + ); + return c.json({ error: 'Queue publish failed' }, 500); + } - return c.json({ received: true }); // [!code highlight] + return c.json({ received: true }); }); -export default router; +export default app; ``` -## Slack Webhook Example +The route is bounded by signature verification and one queue publish. The slower work happens later in the worker route. + +## Slack: Verify Without an SDK + +Slack signs requests with an HMAC over `v0::`. Skip retries by checking the `x-slack-retry-num` header, and handle the `url_verification` challenge inline. -```typescript title="src/api/webhooks/route.ts" +```typescript title="src/index.ts" +import { Hono } from 'hono'; import { createHmac, timingSafeEqual } from 'node:crypto'; -import slackHandler from '@agent/slack-handler/agent'; +import { QueueClient } from '@agentuity/queue'; + +const SLACK_QUEUE = 'slack-events'; +const SLACK_REPLAY_WINDOW_SECONDS = 60 * 5; -router.post('/slack', async (c) => { - // Slack retries on failure, skip duplicates +const queue = new QueueClient(); +const app = new Hono(); + +app.post('/api/webhooks/slack', async (c) => { + // Slack retries on errors; skip duplicates by acknowledging retries directly if (c.req.header('x-slack-retry-num')) { return c.text('OK'); } const rawBody = await c.req.text(); - const timestamp = c.req.header('x-slack-request-timestamp'); const signature = c.req.header('x-slack-signature'); const secret = process.env.SLACK_SIGNING_SECRET; @@ -90,12 +120,18 @@ router.post('/slack', async (c) => { const payload: unknown = JSON.parse(rawBody); - if (isSlackUrlVerification(payload)) { + if (typeof payload !== 'object' || payload === null) { + return c.text('Invalid payload', 400); + } + + if (isUrlVerification(payload)) { return c.text(payload.challenge); } - c.waitUntil(async () => { // [!code highlight] - await slackHandler.run(payload); + const eventId = isEventCallback(payload) ? payload.event_id : crypto.randomUUID(); + + await queue.publish(SLACK_QUEUE, payload, { + idempotencyKey: eventId, }); return c.text('OK'); @@ -113,11 +149,11 @@ function verifySlackSignature( if (!Number.isFinite(timestampSeconds)) return false; const ageSeconds = Math.abs(Date.now() / 1000 - timestampSeconds); - if (ageSeconds > 60 * 5) return false; + if (ageSeconds > SLACK_REPLAY_WINDOW_SECONDS) return false; - const expected = 'v0=' + createHmac('sha256', secret) - .update(`v0:${timestamp}:${rawBody}`) - .digest('hex'); + const expected = + 'v0=' + + createHmac('sha256', secret).update(`v0:${timestamp}:${rawBody}`).digest('hex'); const expectedBuffer = Buffer.from(expected, 'utf8'); const signatureBuffer = Buffer.from(signature, 'utf8'); @@ -126,28 +162,76 @@ function verifySlackSignature( return timingSafeEqual(expectedBuffer, signatureBuffer); } -function isSlackUrlVerification( +function isUrlVerification( payload: unknown ): payload is { type: 'url_verification'; challenge: string } { - return ( - typeof payload === 'object' && - payload !== null && - 'type' in payload && - payload.type === 'url_verification' && - 'challenge' in payload && - typeof payload.challenge === 'string' - ); + if (typeof payload !== 'object' || payload === null) return false; + if (!('type' in payload) || payload.type !== 'url_verification') return false; + return 'challenge' in payload && typeof payload.challenge === 'string'; } + +function isEventCallback(payload: unknown): payload is { event_id: string } { + if (typeof payload !== 'object' || payload === null) return false; + return 'event_id' in payload && typeof payload.event_id === 'string'; +} + +export default app; +``` + +## Process Events on the Worker Side + +Configure an HTTP destination on the queue once. Every published event is delivered to the worker route, which deserializes the payload and runs the slow work. + +```bash +agentuity cloud queue create worker --name stripe-events --max-retries 3 --visibility-timeout 120 +agentuity cloud queue destinations create stripe-events \ + --type http \ + --name stripe-events-worker \ + --url https:///api/workers/stripe-events +``` + +```typescript title="src/index.ts" +import { Hono } from 'hono'; +import type Stripe from 'stripe'; + +const app = new Hono(); + +app.post('/api/workers/stripe-events', async (c) => { + // The queue posts the published payload back as the request body + const event = await c.req.json(); + + switch (event.type) { + case 'checkout.session.completed': + // process the session + break; + case 'invoice.payment_failed': + // notify the customer, retry the charge, etc + break; + default: + // quietly ignore events you do not handle + break; + } + + return c.json({ ok: true }); +}); + +export default app; ``` -## Key Points + +The queue calls your worker route over HTTPS. `localhost` is not reachable from the platform. Use a tunnel like ngrok during local development, or deploy the app and point the destination at the deployed URL. + + +## Notes -- **Raw body first**: Read body as text before parsing for signature verification -- **Fast response**: Return a 2xx quickly, process with `waitUntil` -- **Error handling**: Store failed webhooks for retry/debugging -- **Signature verification**: Always verify webhooks from external services +- read the body as text **before** parsing it; signature verification needs the exact bytes the sender hashed +- return non-2xx **only** when the request is invalid or the queue publish fails; everything else should respond `200` +- use a stable identifier (`event.id`, `event_id`, request ID) as the queue `idempotencyKey` so retries do not duplicate work +- keep secrets in environment variables; do not check them in +- Hono's `c.executionCtx?.waitUntil()` is a Cloudflare Workers feature. For Bun and Node, hand work off via [Queues](/services/queues) instead -## See Also +## Next Steps -- [HTTP Routes](/routes/http) for route patterns -- [Calling Agents from Routes](/routes/calling-agents) for `c.waitUntil()` handoff patterns +- [Queues](/services/queues): publish API, partitioning, idempotency, and HTTP destinations +- [Background Work](/build/background-work): wire request, status, worker, and stream routes around a queue +- [Webhooks](/services/webhooks): managed ingest URLs with receipts and delivery retries when you do not want to verify signatures yourself diff --git a/docs/src/web/content/cookbook/tutorials/meta.json b/docs/src/web/content/cookbook/tutorials/meta.json index 9e05bce81..3f4c83c62 100644 --- a/docs/src/web/content/cookbook/tutorials/meta.json +++ b/docs/src/web/content/cookbook/tutorials/meta.json @@ -1,4 +1,4 @@ { "title": "Tutorials", - "pages": ["understanding-agents", "rag-agent"] + "pages": ["rag-agent"] } diff --git a/docs/src/web/content/cookbook/tutorials/rag-agent.mdx b/docs/src/web/content/cookbook/tutorials/rag-agent.mdx index 57343e7e4..a3dacb8e0 100644 --- a/docs/src/web/content/cookbook/tutorials/rag-agent.mdx +++ b/docs/src/web/content/cookbook/tutorials/rag-agent.mdx @@ -1,432 +1,271 @@ --- title: Build a RAG Agent short_title: RAG Agent -description: Create a retrieval-augmented generation agent with vector search and citations +description: Index documents into vector storage, retrieve the closest matches, and answer with citations --- -This tutorial walks through building a RAG (Retrieval-Augmented Generation) agent that answers questions using your own knowledge base. The agent indexes documents into vector storage, retrieves the closest matches, and cites the sources it used. +This tutorial builds a question-answering route on top of a vector index. The route searches a namespace for relevant documents, fetches the original text, asks a model to answer using that text, and returns the answer with source citations. -## What You'll Build +The agent itself is a plain typed function. The route owns HTTP. The vector index lives in Agentuity vector storage. -A question-answering agent that: -- Searches a vector database for relevant content -- Uses retrieved documents as context for the LLM -- Returns answers with source citations -- Handles cases where no relevant information is found +## What You Build -## Prerequisites +- a `POST /api/knowledge/index` route that adds documents to a vector namespace +- a `POST /api/knowledge` route that answers a question with retrieved context and source IDs +- one shared agent function that both routes can reuse -- An Agentuity project ([Quickstart](/get-started/quickstart) if you need one) -- Basic familiarity with [Vector Storage](/services/storage/vector) +## Install -## Project Structure - -``` -src/agent/knowledge/ -└── agent.ts # RAG agent logic -src/agent/indexer/ -└── agent.ts # Document indexing logic -src/api/ -└── index.ts # Query and indexing endpoints +```bash +bun add hono ai @ai-sdk/openai @agentuity/vector@alpha @agentuity/schema@alpha zod ``` - +`@agentuity/vector` reads `AGENTUITY_SDK_KEY` from the environment, so run the route with `agentuity dev` or a linked Agentuity project. - -## Create the Agent +## Index Documents -When a user asks a question, the agent needs to: +Indexing accepts a list of documents, calls `vector.upsert()` once per document, and returns the count. Upserts are idempotent: the same `id` overwrites the existing vector. -1. Search the vector database for relevant documents -2. Build context from the search results -3. Generate an answer using the LLM with that context -4. Return the answer with source citations +```typescript title="src/lib/index-documents.ts" +import { VectorClient } from '@agentuity/vector'; +import { s } from '@agentuity/schema'; -```typescript title="src/agent/knowledge/agent.ts" -import { createAgent } from '@agentuity/runtime'; -import { generateText } from 'ai'; -import { openai } from '@ai-sdk/openai'; -import { z } from 'zod'; - -interface KnowledgeMetadata extends Record { - readonly title: string; -} +const documentSchema = s.object({ + id: s.string(), + title: s.string(), + content: s.string(), + category: s.optional(s.string()), +}); -const agent = createAgent('Knowledge Agent', { - description: 'Answers questions using a knowledge base', - schema: { - input: z.object({ - question: z.string().describe('The question to answer'), - }), - output: z.object({ - answer: z.string(), - sources: z.array(z.object({ - id: z.string(), - title: z.string(), - relevance: z.number(), - })), - confidence: z.number().min(0).max(1), - }), - }, - handler: async (ctx, input) => { - ctx.logger.info('Searching knowledge base', { question: input.question }); - - // Search for relevant documents - const results = await ctx.vector.search('knowledge-base', { // [!code highlight] - query: input.question, // [!code highlight] - limit: 5, - similarity: 0.7, // [!code highlight] - }); +const indexInputSchema = s.object({ + documents: s.array(documentSchema), +}); - // Handle no results - if (results.length === 0) { - ctx.logger.info('No relevant documents found'); - return { - answer: "I couldn't find relevant information to answer your question.", - sources: [], - confidence: 0, - }; - } - - const documents = await ctx.vector.getMany( - 'knowledge-base', - ...results.map((result) => result.key) - ); - - // Build context from the matched documents - const context = results // [!code highlight] - .map((r, i) => `[${i + 1}] ${documents.get(r.key)?.document ?? ''}`) // [!code highlight] - .join('\n\n'); - - ctx.logger.debug('Built context from documents', { - documentCount: results.length - }); +type IndexInput = s.infer; - // Generate answer with LLM - const { text } = await generateText({ - model: openai('gpt-5-mini'), - system: `You are a helpful assistant that answers questions based on provided context. -Only use information from the context. If the context doesn't contain the answer, say so. -Cite sources using [1], [2], etc. when referencing specific information.`, - prompt: `Context: -${context} +const VECTOR_NAMESPACE = 'knowledge-base'; +const vector = new VectorClient(); -Question: ${input.question} +export async function indexDocuments(body: unknown): Promise<{ indexed: number }> { + const input = indexInputSchema.parse(body); -Answer the question using only the provided context. Cite your sources.`, + for (const doc of input.documents) { + await vector.upsert(VECTOR_NAMESPACE, { + key: doc.id, + document: `${doc.title}\n\n${doc.content}`, + metadata: { + title: doc.title, + category: doc.category ?? 'general', + indexedAt: new Date().toISOString(), + }, }); + } - // Calculate confidence from average similarity - const avgSimilarity = results.reduce((sum, r) => sum + r.similarity, 0) / results.length; - - return { - answer: text, - sources: results.map((r, i) => ({ - id: r.key, - title: r.metadata?.title ?? `Document ${i + 1}`, - relevance: r.similarity, - })), - confidence: avgSimilarity, - }; - }, -}); + return { indexed: input.documents.length }; +} -export default agent; +export type { IndexInput }; ``` - - -## Add an Indexing Agent +`document` is the searchable text. Vector storage generates the embeddings server-side. Pass `embeddings` directly only when you already have vectors from another model. -Before you can query your knowledge base, you need to populate it. A separate indexing agent handles this by: +## Answer Questions -1. Accepting an array of documents -2. Storing each document in the vector database with metadata -3. Returning the count and IDs of indexed documents +The answer function searches the namespace, fetches the original text for the matches with `getMany()`, and asks the model to write an answer that cites which source it used. The model output is validated by the schema, so the route can return a typed object straight to the client. -```typescript title="src/agent/indexer/agent.ts" -import { createAgent } from '@agentuity/runtime'; +```typescript title="src/lib/answer-question.ts" +import { VectorClient } from '@agentuity/vector'; +import { openai } from '@ai-sdk/openai'; +import { generateText, Output } from 'ai'; import { z } from 'zod'; -const DocumentSchema = z.object({ - id: z.string(), - title: z.string(), - content: z.string(), - category: z.string().optional(), +type KnowledgeMetadata = { + readonly title: string; + readonly category: string; + readonly indexedAt: string; +} & Record; + +const answerSchema = z.object({ + answer: z.string().describe('Answer in plain prose, citing sources with [1], [2], etc.'), + citedIndexes: z + .array(z.number().int().min(1)) + .describe('1-based indexes of the sources the answer used'), }); -const agent = createAgent('Document Indexer', { - description: 'Indexes documents into the knowledge base', - schema: { - input: z.object({ - documents: z.array(DocumentSchema), - }), - output: z.object({ - indexed: z.number(), - ids: z.array(z.string()), - }), - }, - handler: async (ctx, input) => { - ctx.logger.info('Indexing documents', { count: input.documents.length }); - - const ids: string[] = []; - - for (const doc of input.documents) { - await ctx.vector.upsert('knowledge-base', { // [!code highlight] - key: doc.id, // [!code highlight] - document: doc.content, // [!code highlight] - metadata: { - title: doc.title, - category: doc.category, - indexedAt: new Date().toISOString(), - }, - }); - ids.push(doc.id); - } - - ctx.logger.info('Indexing complete', { indexed: ids.length }); +type AnswerOutput = z.infer; + +const VECTOR_NAMESPACE = 'knowledge-base'; +const SEARCH_LIMIT = 5; +const MIN_SIMILARITY = 0.7; +const vector = new VectorClient(); + +interface AnswerResult { + readonly answer: string; + readonly sources: ReadonlyArray<{ + readonly id: string; + readonly title: string; + readonly relevance: number; + }>; + readonly confidence: number; +} + +export async function answerQuestion(question: string): Promise { + const matches = await vector.search(VECTOR_NAMESPACE, { + query: question, + limit: SEARCH_LIMIT, + similarity: MIN_SIMILARITY, + }); + + if (matches.length === 0) { return { - indexed: ids.length, - ids, + answer: "I couldn't find relevant information to answer that question.", + sources: [], + confidence: 0, }; - }, -}); + } + + // search() returns ids and metadata, but not the original document text + // getMany() returns a Map keyed by the same `key`, with `document` populated + const documents = await vector.getMany( + VECTOR_NAMESPACE, + ...matches.map((match) => match.key) + ); -export default agent; + const context = matches + .map((match, i) => { + const stored = documents.get(match.key); + const document = stored?.document ?? ''; + return `[${i + 1}] ${document}`; + }) + .join('\n\n'); + + const { output } = await generateText({ + model: openai('gpt-5.5'), + output: Output.object({ schema: answerSchema }), + system: + 'Answer using only the provided context. If the context is insufficient, say so. Cite sources with [1], [2], etc.', + prompt: `Context:\n${context}\n\nQuestion: ${question}`, + }); + + return { + answer: output.answer, + sources: matches.map((match, i) => ({ + id: match.key, + title: match.metadata?.title ?? `Document ${i + 1}`, + relevance: match.similarity, + })), + confidence: + matches.reduce((sum, match) => sum + match.similarity, 0) / matches.length, + }; +} + +export type { AnswerOutput }; ``` - - -## Create the Route +A few details worth calling out: + +- `vector.search()` returns `id`, `key`, `similarity`, optional `metadata`, and optional `expiresAt`. It does not return the original document text. +- `vector.getMany()` returns a `Map`, with each entry shaped like a search result plus `document` and `embeddings`. +- The metadata generic must satisfy `Record`. Use a `type` intersection (`& Record`) so the generic accepts an interface that has known fields. +- The structured output schema's `.describe()` strings are passed to the model and reduce drift on enum or numeric fields. + +## Wire the Routes -The route exposes both agents over HTTP. Use `agent.validator()` for type-safe validation using each agent's schema. +The Hono app pulls both functions together. Validation happens at the route boundary; the agent functions take typed input. -```typescript title="src/api/index.ts" +```typescript title="src/index.ts" import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; -import knowledgeAgent from '@agent/knowledge/agent'; -import indexerAgent from '@agent/indexer/agent'; +import { s } from '@agentuity/schema'; +import { indexDocuments } from './lib/index-documents'; +import { answerQuestion } from './lib/answer-question'; -const router = new Hono(); +const askSchema = s.object({ + question: s.string(), +}); + +const app = new Hono(); -router.post('/knowledge/index', indexerAgent.validator(), async (c) => { - const data = c.req.valid('json'); - const result = await indexerAgent.run(data); +app.post('/api/knowledge/index', async (c) => { + const body: unknown = await c.req.json(); + const result = await indexDocuments(body); return c.json(result); }); -// Query endpoint - validates using agent's input schema -router.post('/knowledge', knowledgeAgent.validator(), async (c) => { // [!code highlight] - const { question } = c.req.valid('json'); // [!code highlight] - const result = await knowledgeAgent.run({ question }); // [!code highlight] +app.post('/api/knowledge', async (c) => { + const body: unknown = await c.req.json(); + const { question } = askSchema.parse(body); + const result = await answerQuestion(question); return c.json(result); }); -// Health check -router.get('/health', (c) => c.text('OK')); - -export default router; +export default app; ``` - - -## Test Your Agent +## Try It -With both agents created, you can test the full flow: index some documents, then query them. - -Start the dev server: +Run the app locally: ```bash agentuity dev ``` -The examples below assume your API router is mounted at `/api` in `app.ts`, as shown in the [quickstart](/get-started/quickstart). - -Index some test documents: +Index a few documents: ```bash curl -X POST http://localhost:3500/api/knowledge/index \ - -H "Content-Type: application/json" \ + -H 'content-type: application/json' \ -d '{ "documents": [ { "id": "doc-1", "title": "Getting Started", - "content": "Agentuity is a full-stack platform for building AI agents. You can create agents using TypeScript and deploy them with a single command." + "content": "Agentuity ships service clients you can call from any framework. Start with `agentuity create`." }, { "id": "doc-2", "title": "Storage Options", - "content": "Agentuity provides three storage options: key-value for simple data, vector for semantic search, and object storage for files." + "content": "Agentuity exposes key-value, vector, object, and durable stream storage as direct clients." } ] }' ``` -Query the knowledge base: +Ask a question: ```bash curl -X POST http://localhost:3500/api/knowledge \ - -H "Content-Type: application/json" \ - -d '{"question": "What storage options does Agentuity provide?"}' + -H 'content-type: application/json' \ + -d '{ "question": "What kinds of storage does Agentuity offer?" }' ``` -Expected response: +The response includes the model answer, the source IDs the model cited, and an aggregate confidence score derived from match similarity. -```json -{ - "answer": "Agentuity provides three storage options [2]: key-value storage for simple data, vector storage for semantic search, and object storage for files.", - "sources": [ - { "id": "doc-2", "title": "Storage Options", "relevance": 0.89 } - ], - "confidence": 0.89 -} -``` +## Variations -### Frontend +- **Filter results before retrieval.** Search broadly, then narrow the result set before calling `getMany()`. -Build a search interface for your knowledge base: - -```tsx title="src/web/App.tsx" -import { useState } from 'react'; - -interface Source { - id: string; - title: string; - relevance: number; -} + ```typescript + const matches = await vector.search('knowledge-base', { + query: question, + }); -interface KnowledgeResult { - answer: string; - sources: Source[]; - confidence: number; -} - -export function App() { - const [question, setQuestion] = useState(''); - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(false); - - const handleSearch = async () => { // [!code highlight] - if (!question.trim()) return; // [!code highlight] - setIsLoading(true); // [!code highlight] - setError(null); // [!code highlight] - try { // [!code highlight] - const res = await fetch('/api/knowledge', { // [!code highlight] - method: 'POST', // [!code highlight] - headers: { 'Content-Type': 'application/json' }, // [!code highlight] - body: JSON.stringify({ question }), // [!code highlight] - }); // [!code highlight] - if (!res.ok) { // [!code highlight] - setData(null); // [!code highlight] - setError(`Search failed (${res.status})`); // [!code highlight] - return; // [!code highlight] - } // [!code highlight] - setData(await res.json()); // [!code highlight] - } catch { // [!code highlight] - setData(null); // [!code highlight] - setError('Network error'); // [!code highlight] - } finally { // [!code highlight] - setIsLoading(false); // [!code highlight] - } // [!code highlight] - }; // [!code highlight] - - return ( -
-

Knowledge Search

- -
- setQuestion(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSearch()} - placeholder="Ask a question..." - disabled={isLoading} - style={{ flex: 1, padding: '0.75rem' }} - /> - -
- - {error &&

{error}

} - - {data && ( -
-
-

{data.answer}

-
- -
- Confidence: {Math.round(data.confidence * 100)}% -
- - {data.sources.length > 0 && ( -
-

Sources

-
    - {data.sources.map((source) => ( -
  • - {source.title} - - ({Math.round(source.relevance * 100)}% relevant) - -
  • - ))} -
-
- )} -
- )} -
+ const pricingMatches = matches.filter( + (match) => match.metadata?.category === 'pricing' ); -} -``` -Render the app directly: - -```tsx title="src/web/frontend.tsx" -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import { App } from './App'; - -const root = document.getElementById('root'); -if (!root) { - throw new Error('Root element not found'); -} - -createRoot(root).render( - - - -); -``` -
- -
- -## Summary - -| Concept | Description | -|---------|-------------| -| **Vector Search** | Find semantically similar documents using `ctx.vector.search()` | -| **Context Building** | Format search results into LLM-readable context with citations | -| **Similarity Threshold** | Filter results by minimum similarity score (e.g., 0.7) | -| **Confidence Score** | Calculate from average similarity of retrieved documents | -| **Indexing Agent** | Separate agent to populate the vector database with documents | + const documents = await vector.getMany( + 'knowledge-base', + ...pricingMatches.map((match) => match.key) + ); + ``` - -For RAG apps, use [LLM as a Judge](/cookbook/patterns/llm-as-a-judge) to check whether generated answers are grounded in the retrieved sources. - +- **Stream the answer.** Swap `generateText` for `streamText` and return a `Response` whose body is the stream. See [Chat and Streaming](/build/chat-and-streaming). +- **Run indexing in the background.** Move `indexDocuments` behind a queue when documents arrive faster than the route should handle them. See [Background Work](/build/background-work). +- **Track answer quality.** Feed the answer and retrieved context to a judge model to grade whether the answer was grounded. See [Build Agents](/build/agents) for the agent-as-function pattern. ## Next Steps -- [LLM as a Judge](/cookbook/patterns/llm-as-a-judge): Review answer quality with model-based checks -- [Streaming Responses](/agents/streaming-responses): Stream longer answers as they are generated -- [Vector Storage](/services/storage/vector): Add metadata filters and advanced search options +- [Vector Storage](/services/storage/vector): metadata generics, TTL behavior, and search options +- [Build Agents](/build/agents): the plain-function pattern for model-backed work +- [Product Search](/cookbook/patterns/product-search): another vector pattern, with metadata filters and recommendations diff --git a/docs/src/web/content/cookbook/tutorials/understanding-agents.mdx b/docs/src/web/content/cookbook/tutorials/understanding-agents.mdx deleted file mode 100644 index 3dd4ec6b9..000000000 --- a/docs/src/web/content/cookbook/tutorials/understanding-agents.mdx +++ /dev/null @@ -1,392 +0,0 @@ ---- -title: Understanding How Agents Work -short_title: Understanding Agents -description: Learn how AI agents use tools, run in loops with stopping conditions, and use LLMs to complete tasks autonomously ---- - -An agent is an LLM call with a feedback loop around it: call a tool, read the result, then decide whether to answer or keep working. The research agent below keeps that loop visible in code. - -## What Makes an Agent "Agentic"? - -A simple LLM call takes input and returns output. An **agent** goes further: it can decide to take actions, observe results, and continue working until the task is done. - -The agent loop follows this pattern: - -1. **Plan**: The LLM receives a prompt and decides what to do -2. **Act**: If the LLM needs data, it requests a tool call -3. **Observe**: The tool executes and returns results -4. **Repeat**: The LLM sees the results and decides: respond to the user, or call another tool? - -This loop continues until the LLM has enough information to answer, or a stopping condition is reached. - -## What You'll Build - -A research agent that: -- Accepts a topic from the user -- Searches Wikipedia for relevant information -- Summarizes findings and returns a response -- Demonstrates the agent loop in action - -## Prerequisites - -- An Agentuity project ([Quickstart](/get-started/quickstart) if you need one) -- Basic familiarity with [AI SDK Integration](/agents/ai-sdk-integration) - -## Project Structure - -``` -src/agent/researcher/ -└── agent.ts # Research agent with tools -src/api/ -└── index.ts # HTTP endpoint -``` - - - - -## Define a Tool - -Tools are functions the LLM can call. Each tool has three parts: - -1. **Description**: Tells the LLM when to use this tool -2. **Input Schema**: Defines what parameters the tool accepts -3. **Execute Function**: The actual code that runs - -```typescript -import { tool } from 'ai'; -import { z } from 'zod'; - -const WikipediaSearchResponseSchema = z.object({ - query: z.object({ - search: z.array( - z.object({ - pageid: z.number(), - snippet: z.string(), - title: z.string(), - }) - ), - }), -}); - -const searchWikipedia = tool({ - description: 'Search Wikipedia for information on a topic', // [!code highlight] - inputSchema: z.object({ // [!code highlight] - query: z.string().describe('The search query'), // [!code highlight] - }), // [!code highlight] - execute: async ({ query }) => { // [!code highlight] - const url = new URL('https://en.wikipedia.org/w/api.php'); - url.search = new URLSearchParams({ - action: 'query', - format: 'json', - list: 'search', - origin: '*', - srlimit: '3', - srsearch: query, - }).toString(); - - const response = await fetch(url); - const data = WikipediaSearchResponseSchema.parse(await response.json()); - - return data.query.search.map((result) => ({ - title: result.title, - snippet: result.snippet.replace(/<[^>]*>/g, ''), // Wikipedia snippets include HTML tags - pageId: result.pageid, - })); - }, -}); -``` - -The schema is converted to JSON and sent to the LLM, which uses the description and parameter definitions to understand when and how to call the tool. - - - -## Create the Agent with Tools - -The AI SDK's `generateText` function orchestrates the agent loop automatically. When you provide tools, it handles the back-and-forth between the LLM and tool execution. - -```typescript title="src/agent/researcher/agent.ts" -import { createAgent } from '@agentuity/runtime'; -import { generateText, tool, stepCountIs } from 'ai'; -import { openai } from '@ai-sdk/openai'; -import { z } from 'zod'; - -const WikipediaSearchResponseSchema = z.object({ - query: z.object({ - search: z.array( - z.object({ - pageid: z.number(), - snippet: z.string(), - title: z.string(), - }) - ), - }), -}); - -const searchWikipedia = tool({ - description: 'Search Wikipedia for information on a topic', - inputSchema: z.object({ - query: z.string().describe('The search query'), - }), - execute: async ({ query }) => { - const url = new URL('https://en.wikipedia.org/w/api.php'); - url.search = new URLSearchParams({ - action: 'query', - format: 'json', - list: 'search', - origin: '*', - srlimit: '3', - srsearch: query, - }).toString(); - - const response = await fetch(url); - const data = WikipediaSearchResponseSchema.parse(await response.json()); - - return data.query.search.map((result) => ({ - title: result.title, - snippet: result.snippet.replace(/<[^>]*>/g, ''), // Wikipedia snippets include HTML tags - pageId: result.pageid, - })); - }, -}); - -const agent = createAgent('Research Agent', { - description: 'Researches topics using Wikipedia', - schema: { - input: z.object({ topic: z.string() }), - output: z.object({ summary: z.string(), sourcesUsed: z.number() }), - }, - handler: async (ctx, input) => { - ctx.logger.info('Starting research', { topic: input.topic }); - - const result = await generateText({ - model: openai('gpt-5-mini'), - system: `You are a research assistant. Use the search tool to find information, -then synthesize what you learn into a helpful summary. Always search before answering.`, - prompt: `Research this topic and provide a summary: ${input.topic}`, - tools: { searchWikipedia }, // [!code highlight] - stopWhen: stepCountIs(5), // [!code highlight] - }); - - ctx.logger.info('Research complete', { - steps: result.steps.length, - toolCalls: result.toolCalls.length, - }); - - return { - summary: result.text, - sourcesUsed: result.toolCalls.length, - }; - }, -}); - -export default agent; -``` - - - -## Understanding the Loop - -When you call `generateText` with tools, here's what happens: - -1. **Initial Request**: The LLM receives the prompt, system message, and tool definitions -2. **Decision**: The LLM analyzes the request and decides to call `searchWikipedia` -3. **Tool Execution**: The AI SDK validates parameters and runs the `execute` function -4. **Result Injection**: Tool results are added to the conversation -5. **Continue or Finish**: The LLM sees results and either calls another tool or returns a final response - -The `stopWhen` option controls when the loop ends. Use `stepCountIs(n)` to limit iterations and prevent runaway agents: - -```typescript -import { generateText, stepCountIs } from 'ai'; - -const result = await generateText({ - model: openai('gpt-5-mini'), - prompt: 'Research quantum computing', - tools: { searchWikipedia }, - stopWhen: stepCountIs(5), // [!code highlight] -}); - -// Inspect what happened -ctx.logger.info(`Completed in ${result.steps.length} steps`); -ctx.logger.info(`Made ${result.toolCalls.length} tool calls`); -``` - -You can combine multiple stopping conditions. The loop stops when any condition is met, so use `hasToolCall()` for a terminal tool, not an exploratory one like `searchWikipedia`: - -```typescript -import { generateText, stepCountIs, hasToolCall, tool } from 'ai'; -import { z } from 'zod'; - -const finalAnswer = tool({ - description: 'Return the final researched answer', - inputSchema: z.object({ - summary: z.string(), - }), -}); - -const result = await generateText({ - model: openai('gpt-5-mini'), - prompt: 'Research quantum computing', - tools: { searchWikipedia, finalAnswer }, - stopWhen: [stepCountIs(10), hasToolCall('finalAnswer')], // [!code highlight] -}); -``` - - - -## Add the Route - -Create an HTTP endpoint to call your agent: - -```typescript title="src/api/index.ts" -import { Hono } from 'hono'; -import type { Env } from '@agentuity/runtime'; -import researchAgent from '@agent/researcher'; - -const api = new Hono() - .post('/research', researchAgent.validator(), async (c) => { // [!code highlight] - const { topic } = c.req.valid('json'); // [!code highlight] - const result = await researchAgent.run({ topic }); // [!code highlight] - return c.json(result); - }); - -export type ApiRouter = typeof api; - -export default api; -``` - -Mount that router in your project-root `app.ts`: - -```typescript title="app.ts" -import { createApp } from '@agentuity/runtime'; -import api from './src/api/index'; -import researchAgent from './src/agent/researcher/agent'; - -export default await createApp({ - router: { path: '/api', router: api }, - agents: [researchAgent], -}); -``` - - - -## Test It - -Start the dev server: - -```bash -agentuity dev -``` - -### Using curl - -```bash -curl -X POST http://localhost:3500/api/research \ - -H "Content-Type: application/json" \ - -d '{"topic": "how do AI agents work"}' -``` - -### Frontend - -Create a simple frontend to interact with your agent: - -```tsx title="src/web/App.tsx" -import { hc } from 'hono/client'; -import { useState } from 'react'; -import type { ApiRouter } from '../api'; - -const client = hc('/api'); - -export function App() { - const [topic, setTopic] = useState(''); - const [data, setData] = useState<{ summary: string; sourcesUsed: number } | null>(null); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(false); - - const handleResearch = async () => { // [!code highlight] - setIsLoading(true); // [!code highlight] - setError(null); // [!code highlight] - - try { - const res = await client.research.$post({ json: { topic } }); // [!code highlight] - if (!res.ok) { - throw new Error(`Research request failed with ${res.status}`); - } - - setData(await res.json()); // [!code highlight] - } catch (err) { - setError(err instanceof Error ? err.message : 'Request failed'); - } finally { - setIsLoading(false); // [!code highlight] - } - }; // [!code highlight] - - return ( -
-

Research Agent

- -
- setTopic(e.target.value)} - placeholder="Enter a topic to research" - disabled={isLoading} - style={{ flex: 1, padding: '0.5rem' }} - /> - -
- - {error &&

{error}

} - - {data && ( -
-

{data.summary}

- Sources used: {data.sourcesUsed} -
- )} -
- ); -} -``` - -Because this page uses `hc()` directly, you can render the app normally in your entry point: - -```tsx title="src/web/frontend.tsx" -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import { App } from './App'; - -const root = document.getElementById('root'); -if (!root) { - throw new Error('Root element not found'); -} - -createRoot(root).render( - - - -); -``` - -Check the logs to see the agent loop in action: the search tool being called, results being processed, and the final summary being generated. -
- -
- -## Summary - -| Concept | Description | -|---------|-------------| -| **Tool** | A function the LLM can call, defined with `inputSchema` and `execute` | -| **Agent Loop** | Plan → Act → Observe → Repeat until done | -| **stopWhen** | Controls when the loop ends (e.g., `stepCountIs(5)`) | -| **stepCountIs** | Built-in condition to limit loop iterations | -| **generateText** | AI SDK function that orchestrates the loop automatically | - -## Next Steps - -- [AI SDK Integration](/agents/ai-sdk-integration): More patterns for tool use and streaming -- [Calling Other Agents](/agents/calling-other-agents): Build multi-agent systems diff --git a/docs/src/web/content/deploy-operate/custom-domains.mdx b/docs/src/web/content/deploy-operate/custom-domains.mdx new file mode 100644 index 000000000..279cc3613 --- /dev/null +++ b/docs/src/web/content/deploy-operate/custom-domains.mdx @@ -0,0 +1,117 @@ +--- +title: Attaching Custom Domains +short_title: Custom Domains +description: Attach your own domain to an Agentuity project, validate DNS, and let TLS provision automatically. +--- + +Attach a custom domain to a project so the deployed app serves traffic at your hostname instead of the default `*.agentuity.run` URL. The domain lives in `agentuity.json`. The CLI validates DNS at deploy time, and the platform issues a TLS certificate on the first request. + +```bash +agentuity project add domain example.com +agentuity project domain check +agentuity deploy +``` + +## Add a Domain + +`agentuity project add domain` writes the domain into `deployment.domains` and validates DNS before saving: + +```bash +agentuity project add domain example.com +agentuity project add domain example.com --skip-validation +agentuity project add domain example.com --dry-run +``` + +`--skip-validation` writes the domain without checking DNS first. Use it when you intend to add the records after committing the change. `--dry-run` previews the file edit without writing. + +After a successful add, `agentuity.json` contains: + +```json title="agentuity.json" +{ + "deployment": { + "domains": ["example.com"] + } +} +``` + +You can also add domains by editing `agentuity.json` directly. Validation runs on the next deploy. + +## DNS Records + +For each domain, point either a CNAME or an A record at the per-project ION host. The CLI prints the exact target after `project add domain` and again during deploy. + +| Record type | Target | Use when | +|-------------|--------|----------| +| CNAME | `p.agentuity.run` (per-project) | The provider supports CNAMEs at the level you're configuring | +| A | First IP resolved from `ion-.agentuity.cloud` | Apex domains (`example.com`, not `app.example.com`) where CNAME flattening isn't available | + +Use a CNAME when you can. The A-record path exists for apex domains where most DNS providers don't allow CNAMEs. + + +The ION host is per-region. If your project is in `use`, the A record resolves through `ion-use.agentuity.cloud`; in `usc`, through `ion-usc.agentuity.cloud`. Check `agentuity.json` for the project region before resolving the A target. + + +## Validate DNS + +```bash +agentuity project domain check +agentuity project domain check --domain example.com +``` + +`project domain check` requires a local `agentuity.json` and verifies each configured domain resolves to the right target. With no `--domain` flag it checks all of them. + +DNS validation also runs as a deploy step before any environment sync or build: + +- During `project add domain`, non-interactive runs fail when DNS is missing unless you pass `--skip-validation`. +- During deploy, the CLI shows the missing records and retries every 5 seconds until DNS resolves or the command is cancelled. + +Run `agentuity project domain check` before CI deploys when DNS changes are part of the release. There is no deploy flag that skips domain validation. + +## TLS Certificates + +After a successful deploy, the CLI fires a HEAD request at each configured domain to nudge the platform's cert issuance. This is non-blocking: the deploy banner prints regardless, and certs also issue on the first real request from any client. + +The first request after a fresh domain attach can take a few seconds while the cert provisions. Subsequent requests use the cached cert. + +## Custom Domains in the Deploy Banner + +When a deployment has at least one custom domain configured, the deploy banner prints the custom URL and dashboard URL: + +```text +[OK] Your project was deployed! + +Deployment: deploy_abc... +-> Deployment: https://example.com +-> Dashboard: https://app.agentuity.com/r/deploy_abc... +``` + +The Agentuity-hosted URLs still exist, but the custom-domain banner focuses on the configured domain. `agentuity project show --json` returns `urls.custom`, and `agentuity cloud deployment show` surfaces configured domains in the `Domains` and `DNS Records` fields. + +## Removing a Domain + +The CLI does not have a `domain remove` subcommand today. Edit `agentuity.json` and delete the entry from `deployment.domains`, then redeploy: + +```bash +# After editing agentuity.json +agentuity deploy +``` + +Removing a domain from `agentuity.json` does not delete DNS records you set. Clean those up at your DNS provider once the deploy with the new config is live. + +## Multiple Domains + +`deployment.domains` accepts any number of domains. Each one is validated independently at deploy time and gets its own TLS cert. Use this for staging vs production hostnames on the same project, or for serving the same app at multiple brands. + +```json title="agentuity.json" +{ + "deployment": { + "domains": ["example.com", "www.example.com", "app.example.com"] + } +} +``` + +## Next Steps + +- [Deploy Framework Apps](/deploy-operate/deploy-framework-apps): the deploy flow that validates and serves your domain. +- [Custom Domains in the CLI Reference](/reference/cli/deployment#custom-domains): full DNS troubleshooting surface. +- [Regions](/reference/cli/deployment#regions): the project region determines the ION host used in A-record DNS. diff --git a/docs/src/web/content/deploy-operate/deploy-framework-apps.mdx b/docs/src/web/content/deploy-operate/deploy-framework-apps.mdx new file mode 100644 index 000000000..2819baa8c --- /dev/null +++ b/docs/src/web/content/deploy-operate/deploy-framework-apps.mdx @@ -0,0 +1,356 @@ +--- +title: Deploying Framework Apps +short_title: Deploy Framework Apps +description: Register, configure, package, and ship a framework project with the Agentuity CLI. +--- + +A framework deploy registers the project once, packages a bundle every time, and uploads an encrypted archive plus static assets to Agentuity. `agentuity deploy` runs the build itself, so you do not need to invoke `agentuity build` first. Run it ahead of time when you want to inspect the output. + +```bash +agentuity project import --validate-only +agentuity project import +agentuity build +agentuity deploy +``` + +## Register the Project + +Validate the directory before doing anything that talks to the cloud: + +```bash +agentuity project import --validate-only +``` + +The check requires a `package.json` with at least one of `name`, `dependencies`, or `devDependencies`, or an `agentuity/` subdirectory with its own `package.json`. Nothing is written, nothing is created. + +When the shape is right, register the project: + +```bash +agentuity project import +agentuity project import --name "my-app" +agentuity project import --org-id +agentuity project import --confirm +``` + +The first run creates a project record in your org, writes `agentuity.json` with `projectId`, `orgId`, `region`, and the project SDK key, and adds `AGENTUITY_SDK_KEY=...` to `.env`. It also pushes any non-reserved values from `.env` into the cloud project so the deploy step has them available. + +`--confirm` makes the command non-interactive: it accepts the default org, default region, and a name derived from the directory unless you pass `--name`. Use it in CI. Without `--confirm` and without a TTY, the import refuses to run. + + +In an interactive terminal, `agentuity deploy` offers to register an unregistered project before it builds. In CI or any non-TTY context, deploy fails fast with `PROJECT_NOT_FOUND` if there is no `agentuity.json`. Run `agentuity project import --confirm` once before the first deploy. + + +## Configure agentuity.json + +`agentuity project import` writes the project link file with sensible defaults. Open it before your first deploy when you want to set the region, custom domains, or runtime limits. + +```json title="agentuity.json" +{ + "$schema": "https://agentuity.dev/schema/cli/v1/agentuity.json", + "projectId": "proj_...", + "orgId": "org_...", + "region": "use", + "deployment": { + "resources": { "memory": "500Mi", "cpu": "500m", "disk": "500Mi" }, + "domains": [] + }, + "build": { + "timeout": "10m", + "resources": { "memory": "4Gi", "cpu": "1000m", "disk": "20Gi" } + } +} +``` + +### Region + +`region` is required. The CLI fetches the live region list from the API and caches it under `~/.config/agentuity/regions-.json`, so a static region table is not part of these docs. List and select regions from the CLI: + +```bash +agentuity cloud region list +agentuity cloud region select use +agentuity cloud region current +``` + +Deploy compares the local region with the server's record. If they diverge, an interactive deploy prompts before changing the project. In CI, pass `--confirm` to accept the change; without it, deploy refuses to update the region. + +### Runtime Resources + +`deployment.resources` controls the running app's CPU, memory, and disk. Defaults apply when the field is omitted. + +| Field | Format | Default | +|-------|--------|---------| +| `memory` | `Mi`, `Gi`, `M`, `G`, raw bytes | `500Mi` | +| `cpu` | `m` (millicores) or fractional cores (`1`, `0.5`) | `500m` | +| `disk` | same as memory | `500Mi` | + +`1000m`, `0.5`, and `1` mean one core, half a core, and one core respectively. There is no CLI flag to override these at deploy time. Edit `agentuity.json` and redeploy. + +### Build Resources + +`build.resources` and `build.timeout` apply to the build environment when the GitHub App or another CI path runs your deploy. They are platform-defaulted when omitted. Bump them when builds time out or run out of memory under heavy framework dependencies. + +### Custom Domains + +Domains live on `deployment.domains` as an array of strings. See [Custom Domains](/deploy-operate/custom-domains) for DNS records, validation behavior, and TLS provisioning. + +## Build and Inspect + +`agentuity deploy` runs the build internally. Run it explicitly when you want to see the detector output, the bundled launch metadata, or the static assets that will be uploaded: + +```bash +agentuity build +agentuity build --skip-type-check +agentuity build --outdir .agentuity-check +agentuity build --report-file build-report.json +``` + +The CLI walks a framework database (Next.js, Nuxt, Remix, React Router, SvelteKit, Astro, SolidStart, TanStack Start, Vite, Parcel, RedwoodJS), then falls back to a generic adapter that requires `scripts.build`, `scripts.start`, or `main` in `package.json`. Hono apps with no `build` script currently fail detection with `BUILD010`. See [Hono Apps](/frameworks/hono) for the build-script setup. + +After the build, `.agentuity/` contains: + +| File or directory | What it is | +|-------------------|------------| +| Framework build output | Mirrored from the framework's build directory, minus `node_modules`, `.git`, `.env`, and `.agentuity` itself | +| `node_modules/` | Copied from the project root | +| `package.json` | Copied from the project root | +| `launch.json` | Process command, framework slug and version, runtime name and port, build timestamp | +| `Procfile` | `web: `, the same start command as `launch.json` for portable runners | +| `.agentuity-build` | JSON marker with framework, runtime, and build date | +| `_serve.js` (static-only frameworks) | Generated Node HTTP server that serves the build output and falls back to `index.html` | + +`launch.json` is the deploy gate's source of truth for what the platform will actually run. Read it before deploy if anything looks off: + +```json title=".agentuity/launch.json" +{ + "processes": [ + { "type": "web", "command": "node node_modules/.bin/next start", "default": true } + ], + "framework": { "name": "nextjs", "version": "15.0.3" }, + "runtime": { "name": "node", "port": 3000 }, + "build": { "date": "2026-04-29T19:23:01.412Z", "duration": 8421 } +} +``` + +The typecheck step runs `bunx tsc --noEmit --skipLibCheck`. `--skip-type-check` and `--dev` skip it; both are useful for fast packaging checks. Use a normal `agentuity build` before deploy when you want the same verification deploy uses. + + +Run `agentuity build --outdir .agentuity-check` to package into a sibling directory. You can `ls`, `cat launch.json`, or `unzip` files without disturbing the `.agentuity/` directory deploy will use. + + +## Deploy + +```bash +agentuity deploy +agentuity deploy --org-id +agentuity deploy --message "ship refresh-token endpoint" +``` + +`agentuity deploy` requires: + +- An authenticated CLI session (`agentuity auth login` if not). +- A registered project (`agentuity.json` present). +- `AGENTUITY_SDK_KEY` available in `.env` (or in `.env.` when not on the local profile). If missing, deploy exits before building and points you to `agentuity cloud env pull`. + +What runs, in order: + +1. **Reconcile the project.** Confirms `agentuity.json` matches a real cloud project. In a TTY with no `agentuity.json`, the CLI offers to import; otherwise it fails with `PROJECT_NOT_FOUND`. +2. **Region check.** Compares the local `agentuity.json` region with the server. Prompts if they diverge or accepts `--confirm` in non-TTY mode. +3. **SDK key check.** Reads `AGENTUITY_SDK_KEY` from the profile-aware env files. +4. **Create the deployment record.** The server returns a deployment ID, an encryption public key, and a stream URL for warmup logs. +5. **Validate custom domains** (only when `agentuity.json` lists them; see [Custom Domains](/deploy-operate/custom-domains#validate-dns)). +6. **Sync env and secrets.** Reads `.env`, drops `AGENTUITY_*` reserved keys, splits into env vars and secrets, and pushes to the cloud project. This step is best effort: a failure is reported as skipped, not fatal. +7. **Build, verify, and package.** Runs typecheck, framework detection, the framework adapter's build, then writes `launch.json`, `Procfile`, and `.agentuity-build`. Uploads the build metadata to receive signed upload URLs. +8. **Security scan.** Compares the project's `node_modules` against a malware blocklist that started running in parallel with the build. A `block` verdict halts the deploy. +9. **Encrypt and upload.** Zips `.agentuity/` (excluding `.env*`, `.git/`, `.ssh/`, `.vite/`, `.DS_Store`), encrypts the archive with FIPS KEM-DEM using the deployment public key, uploads to the signed URL, then uploads any client assets (CDN-bound) with up to four concurrent requests. +10. **Provision.** Calls `projectDeploymentComplete` to start server-side provisioning. +11. **Poll status.** Up to 300 seconds, optionally streaming warmup logs from the deployment's stream URL. + +When provisioning completes, the CLI prints a banner with the deployment URL, the project's "latest" URL, the dashboard URL, and any custom domain URLs you configured. + + +The deploy build step shells out framework binaries (`vite`, `next`, `astro`, etc.) without resolving them through your package manager. If `agentuity deploy` fails with `sh: : command not found`, prepend the local bin directory to `PATH`: + +```bash +PATH="$(pwd)/node_modules/.bin:$PATH" agentuity deploy +``` + +Or wrap deploy in a package script and call it through your package manager, which adds the bin directory automatically: + +```json title="package.json" +{ "scripts": { "deploy": "agentuity deploy" } } +``` + +```bash +bun run deploy +``` + +The package-script wrapper also lets you use `predeploy`/`postdeploy` lifecycle hooks. + + + +`agentuity deploy` runs `bunx tsc --noEmit --skipLibCheck` before packaging. There is no `--skip-type-check` on `deploy` (only on `agentuity build`). When your build fails on a type error, fix the type error or widen `tsconfig.json` (`"target": "es2022"`, `"lib": ["es2022", "dom"]` covers most modern stdlib usage) and re-run. + + +## Read the Output + +The banner is the canonical source for the deployed URL. The output shape looks like this: + +```text +[OK] Your project was deployed! + +Deployment: deploy_abc123... +-> Deployment: https://.agentuity.run +-> Project: https://.agentuity.run +-> Dashboard: https://app.agentuity.com/r/deploy_abc123... +``` + +The same values are reachable later through `project show`: + +```bash +agentuity project show +``` + +The output includes `urls.app` (public URL of the latest deployment), `urls.dashboard`, and `urls.custom` (when a custom domain is attached). + + +Each deploy gets a unique vanity URL tied to that build. The project also has a stable "latest" URL that points to whatever deployment is currently active. Use the deployment URL to verify a specific build, the project URL when you want a stable address. + + +## Validate Before and After Deploy + +```bash +agentuity project import --validate-only # confirm directory shape +agentuity build --report-file report.json # confirm framework detection and bundle output +agentuity deploy --message "smoke check" # ship +curl https:/// # exercise the running deploy +``` + +Use the build report when something fails non-obviously: it captures TypeScript errors with file paths and line numbers, build phase timings, and warnings. The deploy banner gives you the URL; a quick `curl` or browser load confirms the platform actually started the process. + +## Operate a Deployment + +These commands work against deployments after they ship. + +### List, Show, and Read Logs + +```bash +agentuity cloud deployment list # 10 most recent, latest first +agentuity cloud deployment list --count 50 # up to 100 +agentuity cloud deployment show # full metadata +agentuity cloud deployment logs # most recent 100 lines +agentuity cloud deployment logs --limit 500 --no-timestamps +``` + +`cloud deployment show` surfaces: + +- Deployment ID, project ID, state (`completed`, `failed`, `pending`), active flag, tags +- Region, custom domains, attached database/storage resource names +- `Deployment Logs` and `Build Logs` URLs (open in dashboard for live tailing) +- Git metadata (branch, commit, message, provider) +- Build metadata (Agentuity CLI version, Bun version, platform, arch) + + +`deployment logs` is a one-shot fetch with `--limit` capped at 100 by default. For live tailing, open the `Deployment Logs` URL from `cloud deployment show` in your browser. The deploy command itself streams warmup logs in-place during provisioning. + + +### Roll Back + +```bash +agentuity cloud deployment rollback +agentuity cloud deployment rollback --project-id proj_... +``` + +Activates the most recent completed deployment behind the current one. The CLI confirms before switching. The previous active deployment stays at its per-deployment URL but is no longer the project's "latest". + +### Undeploy + +```bash +agentuity cloud deployment undeploy +agentuity cloud deployment undeploy --force +``` + +Stops the active deployment. The project URL returns 404 until a new deploy ships. Per-deployment URLs continue to resolve; only the active one is paused. + +### Delete a Deployment + +```bash +agentuity cloud deployment delete +agentuity cloud deployment delete --force +``` + +Permanently removes a single deployment from the project's history. The deployment URL stops resolving. + +### Delete a Project + +```bash +agentuity project delete # interactive multi-select +agentuity project delete +agentuity project delete --confirm # CI-safe, no prompt +``` + +Deletes the project record, all its deployments, and breaks the local `agentuity.json` link. There is no recovery. + +## Machines + +`agentuity cloud machine` (alias `machines`) lists and manages the underlying compute that runs your deployments. Most users never touch these. They exist for capacity inspection and emergency teardown. + +```bash +agentuity cloud machine list +agentuity cloud machine list --org-id org_... +agentuity cloud machine show +agentuity cloud machine deployments +agentuity cloud machine delete --force +``` + +`machine show` surfaces the instance type, availability zone, private IPv4, and the deployments currently scheduled on it. + +## Lifecycle Scripts + +`agentuity deploy` runs the deploy command directly. Wrap it in a package script when you want lifecycle hooks: + +```json title="package.json" +{ + "scripts": { + "predeploy": "bun run typecheck", + "deploy": "agentuity deploy", + "postdeploy": "bun run notify-team" + } +} +``` + +```bash +bun run deploy +``` + +When the GitHub App runs the deploy in its build environment, it honors the same `predeploy` and `postdeploy` script slots. See [Pre-Deploy Scripts](/reference/github-app#pre-deploy-scripts) for the CI-side behavior. + +## Automating with the GitHub App + +For repository-driven deploys, link the repo with the Agentuity GitHub App: + +```bash +agentuity git link --branch main --deploy --preview +``` + +Pushes to the linked branch trigger a deploy. Pull requests get isolated preview environments that auto-cleanup on close or merge. The build runs from a clean checkout in Agentuity's environment, not from your laptop, and the App passes git metadata (commit, branch, repo, PR number) to deploy as flags. + +See [Automating Deployments with the GitHub App](/reference/github-app) for the full lifecycle, [Push Deploys](/reference/github-app#push-deploys) for branch deploys, and [Preview Environments](/reference/github-app#preview-environments) for PR builds. + +## Coming from a Legacy Project + +For projects on the older deploy path, inspect the migration CLI before changing anything: + +```bash +npx @agentuity/migrate@alpha --help +``` + +The alpha tag includes the migration command for moving an existing project onto the latest CLI. Keep legacy projects on their existing deploy path until you have run the migration and validated the new build. + +## Next Steps + +- [Custom Domains](/deploy-operate/custom-domains): attach your own domain, set DNS, verify TLS. +- [Environment Variables](/deploy-operate/environment-variables): how `.env` values become cloud project vars and secrets. +- [Local Development](/deploy-operate/local-development): iterate against the same project before shipping. +- [Deployment Bundle Contents](/reference/cli/build-configuration#deployment-bundle-contents): full file inventory of `.agentuity/`. +- [Deploy Options](/reference/cli/deployment#deploy-options): every flag accepted by `agentuity deploy`. +- [Runtime and Build Resources](/reference/cli/deployment#runtime-and-build-resources): adjust CPU, memory, and disk in `agentuity.json`. diff --git a/docs/src/web/content/deploy-operate/environment-variables.mdx b/docs/src/web/content/deploy-operate/environment-variables.mdx new file mode 100644 index 000000000..8ae10b2a5 --- /dev/null +++ b/docs/src/web/content/deploy-operate/environment-variables.mdx @@ -0,0 +1,162 @@ +--- +title: Managing Environment Variables +short_title: Environment Variables +description: Manage local .env files, cloud project variables, public values, and secrets. +--- + +Environment variables in an Agentuity project live in three places: local `.env` files for development, the cloud project (and optionally the org) for deployed apps, and the bundle itself for values the build patches in. + +```bash +agentuity cloud env list +agentuity cloud env set OPENAI_API_KEY "sk_..." --secret +agentuity cloud env pull +agentuity cloud env push +``` + +## Local Env Files + +The CLI looks up `AGENTUITY_SDK_KEY` in your `.env` files using the active profile. Default profile is `production`. The local profile (`local`) loads a different file order so dev overrides do not bleed into other profiles. + +| Profile | File order (highest priority first) | +|---------|--------------------------------------| +| `local` (and `NODE_ENV` is not `production`) | `.env.local` → `.env.development` → `.env` | +| any other profile, or `NODE_ENV=production` | `.env.` → `.env` → `.env.production` | + +Switch profiles with `--profile ` or `AGENTUITY_PROFILE=...`. Your framework's runtime reads its own variables. The CLI parses these files only for the SDK key it needs to wire the gateway during `agentuity dev`. + + +`agentuity dev` patches `AGENTUITY_SDK_KEY` and three provider env pairs (see below). Your app reads everything else through your framework's own loader, your shell, or a process manager. Keep `.env` for shared values and `.env.local` for personal overrides. + + +## AI Gateway in Local Dev + +Agentuity's AI Gateway supports nine public provider families: OpenAI, Anthropic, Groq, Google, xAI, DeepSeek, Mistral, Cohere, and Perplexity. Local `agentuity dev` has one automatic env-injection path today. + +For OpenAI, Anthropic, and Groq SDKs, `agentuity dev` patches the API key and base URL env vars directly into the dev process: + +| Provider | API key env var | Base URL env var | +|----------|------------------|------------------| +| OpenAI | `OPENAI_API_KEY` | `OPENAI_BASE_URL` | +| Anthropic | `ANTHROPIC_API_KEY` | `ANTHROPIC_BASE_URL` | +| Groq | `GROQ_API_KEY` | `GROQ_BASE_URL` | + +The patch fires only when the API key var is missing or already equals `AGENTUITY_SDK_KEY`. If you set `OPENAI_API_KEY` to a real OpenAI key in `.env`, the CLI leaves it alone and your call goes straight to the provider. + +See [AI Gateway > Local Development](/services/ai-gateway#local-development) for the canonical provider list and BYOK options. + +## Project SDK Key + +`AGENTUITY_SDK_KEY` is the only Agentuity value the local CLI reads from `.env`. It is written by `agentuity project import` when you register the project, refreshed by `agentuity cloud env pull`, and required by `agentuity deploy`. + +If deploy cannot find the key, it exits before building with a message pointing at `agentuity cloud env pull`. The platform injects `AGENTUITY_SDK_KEY` into the deployed runtime automatically. You should not set or override it for a deployed app. + +## Cloud Project Variables + +`agentuity cloud env` manages variables stored on the cloud project (the default scope) or the organization (`--org`). The deployed app sees the merged view: project values override org values for the same key. + +```bash +agentuity cloud env list # merged project + org view +agentuity cloud env list --org # org-scoped only +agentuity cloud env get OPENAI_API_KEY # one key +agentuity cloud env set API_TIMEOUT "30" +agentuity cloud env set DATABASE_URL "..." --secret +agentuity cloud env delete LEGACY_FLAG +``` + +The `set` command auto-detects whether a value should be stored as a secret. Pass `--secret` to force secret storage. Setting `--secret` on a public-prefixed key (`VITE_*`, `PUBLIC_*`, `AGENTUITY_PUBLIC_*`) is rejected. + +## Pull and Push + +Move values between local `.env` and the cloud project. By default both directions are conservative: `pull` keeps existing local values, `push` skips existing cloud values that would change. + +```bash +agentuity cloud env pull +agentuity cloud env pull --force +agentuity cloud env push +agentuity cloud env push --force +``` + +`pull` writes cloud values into `.env`. Without `--force`, local values win when a key already exists locally. With `--force`, cloud values overwrite local after a confirmation. + +`push` reads `.env`, drops reserved Agentuity keys (see below), classifies the rest, and writes to the cloud project. Without `--force`, the CLI prompts before overwriting an existing cloud value. + +A project pull also writes `AGENTUITY_SDK_KEY` when the cloud project returns one. Use it to recover the key on a fresh checkout. + +## Reserved Agentuity Keys + +`agentuity cloud env push` and the env sync inside `agentuity deploy` filter out variables that the platform owns. Filtered keys never make it to the cloud project. + +| Key pattern | Filtered? | +|-------------|-----------| +| `AGENTUITY_PUBLIC_*` | No, treated as a public env var | +| `AGENTUITY_AUTH_SECRET` | No, allowed through | +| `AGENTUITY_CLOUD_BASE_URL` | No, allowed through | +| Any other `AGENTUITY_*` (including `AGENTUITY_SDK_KEY`, transport URLs, region) | Yes, dropped | + +That keeps platform-managed values from being pinned by a stale local `.env`. + +## Public Variables and Secrets + +The CLI auto-classifies env values when pushing to the cloud project so secrets stay encrypted and public values stay readable. + +Always treated as public env vars (never secrets): + +| Prefix | Use for | +|--------|---------| +| `VITE_` | Vite browser-exposed values | +| `PUBLIC_` | Frameworks that expose public values with this prefix (SvelteKit, Astro) | +| `AGENTUITY_PUBLIC_` | Agentuity public values | + +Always treated as secrets: + +- Keys ending in `_SECRET`, `_KEY`, `_TOKEN`, `_PASSWORD`, or `_PRIVATE` +- The exact key `DATABASE_URL` + +Public-prefix classification beats the suffix rule. `VITE_PUBLIC_KEY` is public, not a secret, even though it ends in `_KEY`. Pass `--secret` on `cloud env set` only when you want to override auto-detection on a non-public key. + + +`VITE_*`, `PUBLIC_*`, and `AGENTUITY_PUBLIC_*` values ship to the browser. Anything visible to a logged-out user belongs in this column. API keys, database URLs, signing secrets, and tokens belong in the secret column. + + +## Deployed Apps + +Deployed apps see three sources of environment values: + +1. **Platform-injected.** `AGENTUITY_SDK_KEY` and the regional transport URLs (`AGENTUITY_TRANSPORT_URL`, `AGENTUITY_KEYVALUE_URL`, `AGENTUITY_VECTOR_URL`, `AGENTUITY_STREAM_URL`, `AGENTUITY_OTLP_URL`, etc.) are set by the platform. You do not set these in cloud project vars. +2. **Cloud project vars and secrets.** Everything you set with `agentuity cloud env set` or sync via `agentuity deploy`. Project values override org values on the same key. +3. **Provider SDK configuration.** The deployed runtime has `AGENTUITY_SDK_KEY` and `AGENTUITY_TRANSPORT_URL`, but provider SDKs still need to be configured for the gateway path you are using. OpenAI, Anthropic, and Groq can use env-driven SDK defaults. AI SDK factory providers, such as Google or xAI, should receive `apiKey` and `baseURL` explicitly. Set the provider's own key when you want to bring your own quota and bypass the gateway. For non-LLM service keys (Stripe, Postmark, Datadog, etc.), set them with `agentuity cloud env set`: + +```bash +# Provider key the gateway already covers (usually unnecessary) +agentuity cloud env set OPENAI_API_KEY "sk-..." --secret + +# Service key only your code reads (required) +agentuity cloud env set STRIPE_SECRET_KEY "sk_live_..." --secret + +# Public value your framework reads at build or render time +agentuity cloud env set PUBLIC_FEATURE_FLAGS '{"newCheckout":true}' +``` + +## Org-Scoped Variables + +Use `--org` for values shared across every project in an org. Project values still override org values on the same key, so `--org` is best for defaults. + +```bash +agentuity cloud env set DATADOG_API_KEY "..." --secret --org +agentuity cloud env list --org +agentuity cloud env pull --org +agentuity cloud env push --org +``` + +## Best Practices + +- **Use `.env.local` for personal overrides.** It is loaded ahead of `.env.development` and `.env` on the local profile and stays out of the synced bundle. +- **Push before deploy if `.env` drifts.** `agentuity deploy` runs the sync best-effort. A failure during sync does not block the deploy, so verify with `agentuity cloud env list` after. +- **Keep gateway and provider-owned paths explicit.** Use the gateway setup documented in [AI Gateway](/services/ai-gateway) when you want Agentuity-routed model calls. Set provider API keys on the cloud project when you want direct provider traffic or when you're calling a non-LLM service. +- **Never check `.env` into git.** Keep a committed `.env.example` for shape and a private `.env` for values. + +## Next Steps + +- [Local Development](/deploy-operate/local-development): the gateway wiring and profile resolution that uses these values. +- [Deploy Framework Apps](/deploy-operate/deploy-framework-apps): how `agentuity deploy` reads `.env` and ships it to the cloud project. +- [Environment Variables on Deploy](/reference/cli/deployment#environment-variables): the deploy step's exact env-sync semantics. diff --git a/docs/src/web/content/deploy-operate/index.mdx b/docs/src/web/content/deploy-operate/index.mdx new file mode 100644 index 000000000..457b68758 --- /dev/null +++ b/docs/src/web/content/deploy-operate/index.mdx @@ -0,0 +1,59 @@ +--- +title: Deploy & Operate +description: Run a framework app locally, package it, deploy it, and manage the env values it needs. +--- + +import { Globe, KeyRound, Play, Rocket } from 'lucide-react'; + +Use these pages when you want to run a framework app locally with Agentuity wiring, register and deploy it, attach a domain, and manage the environment values it reads at each stage. + + + } + /> + } + /> + } + /> + } + /> + + +## Canonical Flow + +```bash +agentuity dev # iterate locally with gateway wiring +agentuity project import --validate-only # confirm the directory looks right +agentuity project import # register the project, write agentuity.json +agentuity build # package .agentuity for deploy +agentuity deploy # encrypt, upload, provision +``` + +The full deploy command runs `build` itself and uploads the result. Run `agentuity build` ahead of time when you want to inspect the bundle, the detected framework, or `launch.json` before shipping. + +## Where to Start + +| Goal | Page | +|------|------| +| Run a framework dev server with `AGENTUITY_SDK_KEY` and gateway base URLs already wired | [Local Development](/deploy-operate/local-development) | +| Register a directory as a cloud project, then ship it | [Deploy Framework Apps](/deploy-operate/deploy-framework-apps) | +| Attach your own domain, set DNS, verify TLS | [Custom Domains](/deploy-operate/custom-domains) | +| Decide what goes in `.env`, what goes in cloud project vars, and what becomes a secret | [Environment Variables](/deploy-operate/environment-variables) | +| Operate after deploy: list, show, rollback, undeploy, delete | [Operate a Deployment](/deploy-operate/deploy-framework-apps#operate-a-deployment) | +| Inspect detector output, `launch.json`, or the bundle directory before deploy | [Building Deployment Bundles](/reference/cli/build-configuration) | +| See every flag for `agentuity deploy` | [Deploying with the CLI](/reference/cli/deployment) | diff --git a/docs/src/web/content/deploy-operate/local-development.mdx b/docs/src/web/content/deploy-operate/local-development.mdx new file mode 100644 index 000000000..69af38229 --- /dev/null +++ b/docs/src/web/content/deploy-operate/local-development.mdx @@ -0,0 +1,133 @@ +--- +title: Running Local Development +short_title: Local Development +description: Run your framework dev script with Agentuity environment wiring. +--- + +`agentuity dev` runs your framework's dev script as a child process and patches Agentuity values into that process's environment. The framework keeps owning the dev server, hot reload, and the route table. Agentuity's job is to wire `AGENTUITY_SDK_KEY` and the AI Gateway base URLs in front of your provider SDKs. + +```bash +agentuity dev +``` + +## What It Runs + +The CLI reads `package.json`, picks the package manager from the lockfile in the project root, and runs the chosen script: + +| Lockfile present | Runner used | +|------------------|-------------| +| `bun.lock` or `bun.lockb` | `bun run + + +``` + + +For richer client-side behavior, use an Astro island with `client:load` and a React or Svelte component instead of the inline ` + +