code-docs/architecture/agent-system.md
The agent system adds AI chat capabilities to Lowdefy apps. Agents are config-driven (YAML) and follow the same type + connectionId + properties pattern used by requests. A top-level agents: key in the Lowdefy config defines one or more agents, each specifying a model, system instructions, tools, and streaming behavior. The system integrates the Vercel AI SDK v6 for multi-provider model access, streaming, and tool execution.
A minimal agent config:
agents:
- id: support_agent
type: ClaudeAgent
connectionId: anthropic
properties:
model: claude-sonnet-4-20250514
instructions: You are a helpful support agent.
tools:
- search_knowledge_base
The agent system separates concerns into four layers, each configured independently:
Connection -- AI SDK provider (Anthropic, OpenAI, Google, Vercel AI Gateway) or config container (Mcp). Holds credentials and creates the provider instance. Configured under connections: just like database connections.
Agent -- The orchestration unit. References a connection, specifies model/instructions/tools/hooks, and controls the tool loop (max steps, stop conditions, pruning). Configured under the top-level agents: key. At runtime, a ToolLoopAgent from the AI SDK manages the multi-step model interaction.
Tool -- API endpoints with description + payloadSchema that make them callable by models. Endpoints own their tool metadata; agents simply reference endpoint IDs. MCP servers, sub-agents, and file system access provide additional tool sources.
Block -- AgentChat and AgentConversations UI components. Pure rendering, no AI config. The block connects to the agent API route via a transport layer and renders the streaming conversation.
This separation means the same endpoint can serve a page button, another endpoint, or an agent tool without duplication. The same agent can power different UIs.
AgentChat. The handleSend function fires onBeforeSend for validation, then calls sendMessage() on the useChat hook.useChat uses a DefaultChatTransport that POSTs to /api/agent/{pageId}/{agentId}?conversationId=... with { messages, urlQuery, sharedState } in the body. sharedState is read from sharedStateRef.current at send time (see Shared State) so the payload always reflects the latest evaluated state.The Next.js catch-all route (pages/api/agent/[...path].js) extracts pageId and agentId from URL segments, validates the request body, and calls callAgent(). See packages/servers/server/pages/api/agent/[...path].js.
callAgent (packages/api/src/routes/agent/callAgent.js) orchestrates the server-side flow:
agents/{agentId}.json via getAgentConfigagentContext ({ conversationId, pageId, sharedState, urlQuery, userId })_user, _secret, _payload) with agentContext as payloadgetAgentResolverresolverContext with callEndpoint, getEndpointConfig, getAgentConfig, getConnectionForAgent, and resolveMcpSources -- these are the capabilities available to the agent runtimeagentType.resolver() with the connection, properties, and contextThe agent resolver (e.g., ClaudeAgent) maps provider-specific properties into providerOptions and delegates to handleAgentChat().
handleAgentChat (packages/utils/ai-utils/src/handleAgentChat.js) is the shared runtime:
buildAgentTools to merge endpoint, MCP, sub-agent, and file system tools (after rejecting any user tool whose name collides with a platform reserved name like update-page-state)sharedState, builds the update-page-state tool via buildUpdatePageStateTool and includes the snapshot in the agent's context blockToolLoopAgent with model, instructions, tools, stop conditions, and hook callbackscreateUIMessageStream, runs the agent inside the stream's execute functionprune config is set, decomposes the stream creation to insert pruneMessages between UI-to-model conversion and agent executiononFinish hooks (awaited) and writes any returned dataParts to the streamuseChat hook updates messages reactively. useAgentEvents watches message changes and fires Lowdefy events (onMessageComplete, onToolCall, onToolResult, onUserMessage, onTitleGenerated, onDataPart).Three build steps handle agent configuration:
packages/build/src/build/buildAgents.js
Validates and normalizes agent configs in a two-pass approach:
First pass (per agent):
connectionId references an existing connectionmodel is defined in properties{ endpointId } objects, MCP strings become { connectionId } objects, sub-agent strings become { agentId } objectsdescription and payloadSchemacommand, HTTP sources have urlbasePath exists on diskid to agentId (internal format: agent:{agentId})Second pass (cross-references):
confirm: true tools (unsupported in sub-agent context)Cycle detection: Uses DFS with an in-stack set to detect circular sub-agent references. Throws ConfigError if a cycle is found.
packages/build/src/build/writeAgents.js
Serializes each agent config to agents/{agentId}.json using serializer.serializeToString, which preserves ~k markers for error tracing at runtime.
packages/build/src/build/writePluginImports/writeAgentImports.js
Generates plugins/agents.js -- an import registry that maps agent type names to their resolver modules. This allows the server to look up agent types dynamically.
packages/build/src/build/copyAgentFileSystems.js
When the config directory differs from the server directory (production builds), copies each unique fileSystem.basePath directory to the server output. Uses a Set to avoid copying the same path twice when multiple agents share a base directory.
buildAgentTools (packages/utils/ai-utils/src/buildAgentTools.js) merges four tool sources into a single tools object for the AI SDK.
Configured as agent.tools[], each referencing an API endpoint ID. At runtime:
context.getEndpointConfig()tool() with the endpoint's description and payloadSchema~k, ~r, ~l) from the schema before passing to the AI SDKcontext.callEndpoint(), which runs the endpoint's full routine (auth, operators, requests)confirm: true sets needsApproval on the tool for client-side approval UIConfigured as agent.mcp[], each source specifying either an HTTP URL or stdio command. MCP connections can be referenced by connectionId -- callAgent resolves these to inline config before the agent runs. At runtime:
createMCPClient from @ai-sdk/mcphttp (default) and stdio transportsclient.tools() and merges them into the tools objectconfirm: true adds needsApproval to all tools from that sourceConfigured as agent.agents[], each referencing another agent ID. At runtime:
buildAgentTools with depth + 1 (max depth: 5)ToolLoopAgent with the sub-agent's own tools and instructionsdescription (defaults to "Delegate task to the {agentId} agent") and inputSchema (defaults to { task: string })toModelOutput to extract the text response for the parent agentConfigured via agent.properties.fileSystem with a basePath. Automatically adds four tools:
| Tool | Purpose | Limits |
|---|---|---|
read-file | Read file contents | 512KB max, truncates with notice |
list-files | List directory with optional glob | No inherent limit |
search-files | Case-insensitive text search | 200 match limit, skips files >1MB |
stat-file | File metadata (size, type, date) | None |
All tools use resolvePath() (packages/utils/ai-utils/src/fileSystem/resolvePath.js) which normalizes the requested path against the base directory and throws if the resolved path escapes the base, preventing path traversal attacks.
The fileSystem tools call Node's fs/promises and glob directly, so they require a Node.js runtime. Edge runtimes (Vercel Edge, Cloudflare Workers, Deno Deploy edge) and browsers cannot execute them, fs/promises is unavailable. Lowdefy's pages router (pages/api/agent/[...path].js in both @lowdefy/server and @lowdefy/server-dev) does not declare runtime: 'edge', so the default Node runtime applies.
The tools are read-only. There is no write-file or delete-file. Serverless platforms with a read-only deployment filesystem (Vercel, AWS Lambda) work without needing /tmp workarounds.
Standard next start (running the built server directory directly): copyAgentFileSystems copies each unique basePath into context.directories.server at build time, so the data sits alongside the built server. This works as long as the entire server directory ships to the host (Docker, Fly.io, Railway, EC2, etc.).
Next.js standalone output (LOWDEFY_BUILD_OUTPUT_STANDALONE=1 in @lowdefy/server), Vercel, and other tracer-based bundlers need extra wiring. Next's file tracer follows static imports to decide what to include in the bundle. basePath is read from agent config at runtime in buildAgentTools.js:202, so the directory is not statically traceable. Without help, the files copied by copyAgentFileSystems would sit on the build host but never make it into the deployed bundle.
The build handles this automatically. copyAgentFileSystems writes a agentFileSystems.json manifest to the server build directory listing every unique basePath. packages/servers/server/next.config.js reads the manifest and feeds the paths into outputFileTracingIncludes under the /api/agent/* route, so the tracer pulls each basePath directory into the standalone output and the Vercel function bundle. App developers don't need to configure anything.
The trade-off is bundle size: pointing an agent at a large directory will bloat the deployment. That's the explicit intent of granting fileSystem access, but worth flagging when sizing deployments.
packages/utils/ai-utils/src/reservedToolNames.js exports RESERVED_PLATFORM_TOOL_NAMES:
update-page-state, read-file, list-files, search-files, stat-file
buildAgentTools rejects any endpoint, MCP, or sub-agent whose name collides with this list with a ConfigError. MCP sources can collide in a non-fatal way: the conflicting MCP tool is skipped and a warning is logged. These names are owned by the platform: update-page-state is injected by handleAgentChat when the chat has sharedState; the four *-file* tools are injected when an agent has a fileSystem basePath.
The sharedState system lets an agent read from, and write back to, the calling page's Lowdefy state. Two things get wired up when AgentChat.sharedState is a non-empty object:
<context>sharedState: ...</context> (when pageContext: true).update-page-state into the agent's tool set. The model calls it with { updates: { key: value, ... } } and the client applies the writes to Lowdefy page state.AgentChat.sharedStateAgentChat takes a sharedState property (evaluated by operators per render, like any other block property):
type: AgentChat
properties:
agentId: support_agent
sharedState:
_state: true # expose whole page state
# or a curated shape:
# legal: { _state: legal_name }
# cart: { _state: cart.items }
sharedStateRef and reads it inside the transport's body() at send time, so the agent sees the freshest value without re-creating the transport on every state change.null. The server only injects the tool when sharedState is a non-empty object.{ messages, urlQuery, sharedState } to /api/agent/{pageId}/{agentId}.callAgent extracts sharedState from the request body, defaults to {}, and puts it on context.agentContext.sharedState. handleAgentChat then:
buildUpdatePageStateTool({ sharedState }). If sharedState is not an object, it returns null. Otherwise, it returns an AI SDK tool() whose:
typeof (or null / array), so the model knows exactly which fields it can write.{ updates: { type: object, additionalProperties: true } }.tools under the name update-page-state.pageContext: true, appends sharedState: {JSON} to the <context> block prepended to the system instructions.update-page-state is a client-executed tool (no server execute). The browser handles the call in AgentChat's onToolCall:
Reads toolCall.input.updates.
Filters writes against Object.keys(sharedStateRef.current). Any key the model hallucinates outside the exposed shape is dropped into ignored and not written. This defends against prompt-injection writes to arbitrary page state.
For the writable subset, fires a registered internal event via methods.triggerEvent({ name: '__updatePageState', event: writable }). The event is registered at block-mount time:
methods.registerEvent({
name: '__updatePageState',
actions: [{ id: 'setState', type: 'SetState', params: { _event: true } }],
});
Routing through registerEvent + triggerEvent with _event: true reuses the action-runner's existing state-write path rather than introducing new block-level setState/getState methods. (An earlier prototype that added block.setState/block.getState was reverted in favour of this pattern, see the merge history for agent-shared-state.)
Responds via addToolOutput({ tool: 'update-page-state', output: { ok: true, written: [...], ignored: [...] } }) so the model can see what took effect.
Object.keys(sharedState) at request time. Patches to keys not in that set are dropped on the client.update-page-state is in RESERVED_PLATFORM_TOOL_NAMES. User tools with that name throw at build/runtime, they cannot shadow the built-in.A typical setup: the page has a form, the agent can both ask questions about what's in the form and fill it in.
- type: AgentChat
properties:
agentId: intake_agent
sharedState:
legal_name: { _state: legal_name }
company: { _state: company }
notes: { _state: notes }
The model now sees those three fields on every turn and can call update-page-state to write them back. Any attempt to write, say, admin: true is silently dropped because admin isn't in the exposed shape.
Hooks are server-side lifecycle callbacks that call API endpoints. Configured under agent.hooks:
hooks:
onStart: [log_agent_start]
onFinish: [generate_title, save_conversation]
These hooks dispatch endpoint calls without awaiting results:
| YAML Key | AI SDK Callback | Fires When |
|---|---|---|
onStart | experimental_onStart | Agent begins processing |
onStepStart | experimental_onStepStart | Each tool loop step starts |
onToolCallStart | experimental_onToolCallStart | Model initiates a tool call |
onToolCallFinish | experimental_onToolCallFinish | Tool execution completes |
onStepFinish | onStepFinish | Each tool loop step finishes |
Hook payloads are cleaned of non-serializable fields (abortSignal, functions, messages) via cleanHookEvent.
onFinish is handled at the stream level, not through the AI SDK callback. After the agent stream completes:
messages -- full UIMessage arraysteps -- per-step records collected via the SDK's onStepFinish (each with text, finishReason, usage, toolCalls, toolResults)toolResults -- flattened steps.flatMap(s => s.toolResults) convenience fieldfinishReason -- overall finish reason from the usage accumulatorisAborted -- currently always false hereusage -- accumulated usage across all stepsagentContext fields spread in: pageId, userId, conversationId, urlQuery, sharedState{ dataParts: [...] }, each data part is written to the response stream[{ type: 'data-chat-title', data: { title } }], and the client receives the title as a stream eventEach provider plugin exports an agent type that maps provider-specific config to providerOptions before delegating to handleAgentChat.
@lowdefy/connection-anthropic)packages/plugins/connections/connection-anthropic/src/connections/Anthropic/ClaudeAgent/ClaudeAgent.js
Maps thinking and effort properties into providerOptions.anthropic:
properties:
model: claude-sonnet-4-20250514
thinking: { type: enabled, budgetTokens: 10000 }
effort: high
@lowdefy/connection-openai)packages/plugins/connections/connection-openai/src/connections/OpenAI/OpenAIAgent/OpenAIAgent.js
Maps reasoningEffort and reasoningSummary into providerOptions.openai:
properties:
model: o3-mini
reasoningEffort: medium
reasoningSummary: auto
@lowdefy/connection-google)packages/plugins/connections/connection-google/src/connections/Google/GeminiAgent/GeminiAgent.js
Maps thinkingConfig and safetySettings into providerOptions.google:
properties:
model: gemini-2.5-pro
thinkingConfig: { thinkingBudget: 5000 }
@lowdefy/connection-ai-gateway)packages/plugins/connections/connection-ai-gateway/src/connections/AIGateway/AIGatewayAgent/AIGatewayAgent.js
Wraps the Vercel AI Gateway (@ai-sdk/gateway). Maps gateway routing props into providerOptions.gateway before delegating to handleAgentChat. The model is in creator/model form (e.g. anthropic/claude-sonnet-4.5):
properties:
model: anthropic/claude-sonnet-4.5 # creator/model id
order: [anthropic, vertex] # preferred provider order
only: [anthropic, vertex] # restrict to these providers
fallbackModels: # fallback models tried in order
- openai/gpt-5-mini
user: '{{ user.id }}' # spend attribution id
tags: [support, prod] # analytics tags
zeroDataRetention: true # ZDR-only routing
providerTimeouts: # per-provider BYOK timeouts (ms)
byok: { anthropic: 15000 }
byok: # request-scoped BYOK creds
anthropic: [{ apiKey: '...' }]
Provider-native options (e.g. Anthropic thinking, OpenAI reasoningEffort) are passed through the gateway by keying providerOptions on the underlying provider slug (providerOptions.anthropic, providerOptions.openai, ...).
Use this when the app needs cross-provider failover, BYOK, or consolidated billing/analytics. Single-provider apps should use the dedicated provider connection (Anthropic, OpenAI, Google) for lower indirection.
@lowdefy/ai-utils)packages/utils/ai-utils/src/AISDKAgent.js
Passes through to handleAgentChat with no provider-specific mapping. Useful for providers that need no special options.
prepareStep rules (packages/utils/ai-utils/src/buildPrepareStep.js) allow per-step config overrides during the tool loop:
prepareStep:
- steps: [1]
toolChoice: required
activeTools: [search_knowledge_base]
- from: 2
toolChoice: auto
temperature: 0.3
Rules are evaluated in order. The first matching rule wins. Each rule can match by:
steps: [1, 3, 5] -- explicit step numbersfrom: 2, to: 4 -- inclusive range (omit to for open-ended)Overridable properties: activeTools, toolChoice, maxOutputTokens, temperature.
The prune config removes older reasoning and tool-call parts to manage context window size. When enabled, handleAgentChat decomposes the standard createAgentUIStream flow to insert pruneMessages from the AI SDK between UI-to-model message conversion and agent execution.
prune:
reasoning: before-last-message
toolCalls: all
emptyMessages: remove
all -- removes all reasoning parts from all messagesbefore-last-message -- keeps reasoning only in the last assistant messagenone -- keeps all reasoningGlobal string or per-tool array:
# Global
prune:
toolCalls: before-last-message
# Per-tool
prune:
toolCalls:
- type: all
tools: [search_files]
- type: before-last-message
packages/plugins/blocks/blocks-antd-x/src/blocks/AgentChat/AgentChat.js
A composite block built on Ant Design X components that provides the full chat UI.
LowdefyChatTransport.js creates a DefaultChatTransport from the AI SDK, configured to POST to /api/agent/{pageId}/{agentId}. The conversationId is passed as a query parameter, urlQuery in the body.
The useChat hook from @ai-sdk/react manages messages, streaming status, and error state. External messages can be synced in via the messages property (for loading saved conversations). Messages are cleared automatically when conversationId changes.
useAgentEvents (packages/plugins/blocks/blocks-antd-x/src/blocks/AgentChat/useAgentEvents.js) bridges AI SDK state changes to Lowdefy's event system using refs to track which events have already fired:
onBeforeSend -- before message is sent (can cancel)onUserMessage -- when a user message appearsonMessageComplete -- when streaming finishes (includes finishReason, full message parts)onToolCall -- when a tool call is initiatedonToolResult -- when a tool call completesonError -- on streaming/transport errorsonStop -- when user stops generationonRegenerate -- when user regenerates a responseonEditMessage -- when user edits a message (truncates history and resends)onDeleteMessage -- when user deletes a messageonFeedback -- when user gives thumbs up/downonSuggestionClick -- when user clicks a suggestion chiponSwitchChange -- when user toggles a sender switchonTitleGenerated -- when a data-chat-title part arrives (from onFinish hook)onDataPart -- when any data part arrives from the streamRegistered via methods.registerMethod for use with CallMethod actions:
regenerate, setMessages, sendMessage, clearMessages, deleteMessage, stop, clearError, scrollToBottom
display: drawer wraps the chat in an Ant Design drawerToolApproval component rendered for tools with needsApprovaldata-suggestions data partsonBeforeSendpackages/plugins/blocks/blocks-antd-x/src/blocks/AgentConversations/AgentConversations.js
A standalone block for conversation list management, decoupled from the chat UI:
items -- conversation list (from state or requests)activeKey -- currently selected conversationmenu -- context menu items per conversationcreation -- "New Chat" button configgroupable -- group conversations with collapsible sectionsEvents: onSelect, onNew, onMenuClick
Developers wire their own persistence. The block renders the list; the app config connects it to AgentChat via shared state (typically conversationId).
| File | Purpose |
|---|---|
packages/utils/ai-utils/src/handleAgentChat.js | Core orchestration: builds tools, creates ToolLoopAgent, manages streaming |
packages/utils/ai-utils/src/buildAgentTools.js | Merges endpoint, MCP, sub-agent, and fileSystem tools |
packages/utils/ai-utils/src/buildUpdatePageStateTool.js | Builds the update-page-state AI SDK tool from the current sharedState |
packages/utils/ai-utils/src/reservedToolNames.js | RESERVED_PLATFORM_TOOL_NAMES -- blocks user tools from shadowing platform |
packages/utils/ai-utils/src/buildPrepareStep.js | Builds step-matching function for dynamic per-step config |
packages/utils/ai-utils/src/AISDKAgentSchema.js | JSON Schema for agent properties validation |
packages/utils/ai-utils/src/AISDKAgent.js | Generic agent resolver (no provider-specific mapping) |
packages/utils/ai-utils/src/fileSystem/resolvePath.js | Path traversal prevention for fileSystem tools |
packages/utils/ai-utils/src/fileSystem/readFile.js | File reading with 512KB truncation |
packages/utils/ai-utils/src/fileSystem/searchFiles.js | Case-insensitive search with 200 match limit |
packages/api/src/routes/agent/callAgent.js | API route handler: loads config, resolves connections, calls resolver |
packages/api/src/routes/agent/getAgentConfig.js | Reads agent JSON from build artifacts |
packages/api/src/routes/agent/getAgentResolver.js | Looks up agent type from plugin registry |
packages/build/src/build/buildAgents.js | Build-time validation, normalization, cycle detection |
packages/build/src/build/writeAgents.js | Serializes agent configs to JSON artifacts |
packages/build/src/build/writePluginImports/writeAgentImports.js | Generates agent type import registry |
packages/build/src/build/copyAgentFileSystems.js | Copies fileSystem directories to server output |
packages/servers/server/pages/api/agent/[...path].js | Next.js API route, SSE streaming |
packages/plugins/blocks/blocks-antd-x/src/blocks/AgentChat/AgentChat.js | Chat block component |
packages/plugins/blocks/blocks-antd-x/src/blocks/AgentChat/LowdefyChatTransport.js | DefaultChatTransport factory |
packages/plugins/blocks/blocks-antd-x/src/blocks/AgentChat/useAgentEvents.js | AI SDK to Lowdefy event bridging |
packages/plugins/blocks/blocks-antd-x/src/blocks/AgentConversations/AgentConversations.js | Conversation list block |
packages/plugins/connections/connection-anthropic/src/connections/Anthropic/ClaudeAgent/ClaudeAgent.js | Anthropic resolver |
packages/plugins/connections/connection-openai/src/connections/OpenAI/OpenAIAgent/OpenAIAgent.js | OpenAI resolver |
packages/plugins/connections/connection-google/src/connections/Google/GeminiAgent/GeminiAgent.js | Google resolver |
packages/plugins/connections/connection-ai-gateway/src/connections/AIGateway/AIGateway.js | AI Gateway connection (createGateway) |
packages/plugins/connections/connection-ai-gateway/src/connections/AIGateway/AIGatewayAgent/AIGatewayAgent.js | AI Gateway resolver (routing opts -> providerOptions.gateway) |
packages/plugins/connections/connection-mcp/src/connections/Mcp/Mcp.js | MCP config-container connection |
Why ToolLoopAgent over a manual loop? The AI SDK's ToolLoopAgent handles the tool call loop, step tracking, streaming protocol, and message format conversion. Lowdefy provides tools and configuration; the SDK manages the execution cycle. This avoids reimplementing retry logic, streaming protocol details, and multi-step orchestration.
Why endpoints as tools? Endpoints already have authentication, connection management, operator evaluation, and composable routines. Adding description and payloadSchema to endpoint config makes them callable by models with no new infrastructure. The same endpoint serves page buttons, other endpoints, and agent tools.
Why separate AgentConversations from AgentChat? Conversation management (persistence, search, grouping) varies widely across apps. Decoupling lets developers wire their own storage backend and UI layout. The two blocks connect through shared state, typically a conversationId in Lowdefy state.
Why hooks call endpoints? Hooks follow the same composable pattern as the rest of Lowdefy's server-side architecture. A hook endpoint can write to a database, call an external API, or generate a chat title -- all using existing connection and request infrastructure. No special hook execution engine needed.
Why MCP connections are config containers? Unlike provider connections that create SDK clients, MCP connections just store transport config (URL, command, headers). The actual MCP client is created at runtime by buildAgentTools because MCP clients are stateful (they maintain a session) and must be created fresh per request and cleaned up after.
Why clean build artifact markers before passing schemas to the AI SDK? Build artifacts contain serializer markers (~k, ~r, ~l, ~arr) as non-enumerable properties and wrapper objects. The AI SDK's jsonSchema() function expects clean JSON Schema. cleanBuildArtifact() strips these markers via JSON.stringify/JSON.parse with a key filter.