openFunctions is easiest to understand if you start from the runtime it is built on:
ToolDefinition -> ToolRegistry -> AIAdapter
Everything else in the framework composes on top of that path.
defineTool() is the source of truth.
A tool bundles:
- a stable name
- a description the AI reads
- a JSON schema for parameters
- an async handler
- optional tags, examples, and tests
- an optional capability contract for authority, side effects, idempotency, and verification
The TypeScript generic helps the developer. The inputSchema helps the model.
ToolRegistry stores tools once and makes them available everywhere:
- direct execution with
registry.execute() - MCP exposure through
startServer() - provider adapters through
toGeminiFormat(),toAnthropicFormat(), andtoOpenAIFormat() - workflow steps through
toolStep() - agent execution through filtered registries
This is the framework’s universal primitive.
AIAdapter is the provider boundary. Adapters translate the same registered tools into each provider’s tool schema and normalize responses back into:
text- or
toolCall
That is why the same tool set works with Claude, Gemini, OpenAI, xAI, and OpenRouter.
Normal flow:
registry.execute(name, params)- validate params against
inputSchema - run the handler
- return
ToolResult
This same path is used by:
- the MCP server
- the interactive chat loop
- agents
- crews
- workflows
startServer() exposes the registry through Model Context Protocol:
- clients ask for tool schemas
- the server returns registered tools
- clients call a tool
- the server delegates to
registry.execute()
The MCP layer is thin by design. The registry remains the execution engine.
The chat loop repeatedly:
- sends conversation history plus registry tools to an adapter
- checks whether the model returned text or a
toolCall - executes tool calls through the registry
- feeds tool results back into the conversation
This is the simplest place to see the runtime in action without MCP.
Workflows are deterministic composition.
Use them when you know the control flow ahead of time. pipe(), .then(), .parallel(), and .branch() let you build explicit pipelines around:
- tool steps
- transformation steps
- LLM steps
The macro flow is controlled by code. Only the LLM steps are adaptive.
An agent is not a new execution engine. It is:
- a system prompt
- a filtered registry
- a reason/act/observe loop
The filtered registry is the key abstraction. Each agent only sees the tools it should use, selected by explicit tool name or by tag.
Agent loop:
- build the filtered registry
- compose the agent prompt
- call
adapter.chat() - if the model requests a tool, execute it through the filtered registry
- add the tool result to history
- repeat until the model returns text or max rounds is hit
That is why agents stay legible: they are still just tools plus a loop.
Every agent loop also emits a RunRecord. The run captures the exact resolved
model, instruction and capability digests, correlation IDs, limits, and its own
terminal status. A successful text response may create an OutcomeClaim, but
verification and task fulfillment remain separate records. See Legible
runs.
Crews are composition over agents.
- sequential mode threads one agent’s output into the next agent’s prompt context
- parallel mode runs all agents on the same task independently
Delegation is implemented with synthetic tools such as delegate_to_researcher. The model sees delegation as a tool call; the runtime turns that into another agent execution.
This is the main agent pattern.
Instead of giving every model every tool, openFunctions builds a smaller registry for the current task or persona. That keeps behavior tighter and makes agent design understandable.
Several advanced features are built by manufacturing tools at runtime:
- crew delegation tools
- structured-output extraction tools
This is an important design choice: provider-specific features are avoided when a tool-shaped abstraction can solve the same problem portably.
Higher-level modules can expose themselves back into the runtime as tools:
createMemoryTools(...)rag.createTools()connectedProvider.createTools()
That means storage, retrieval, and context systems do not bypass the runtime. They re-enter it.
A context provider connects an external system to the agent runtime. The pattern follows the same composition model as memory and RAG:
ContextProvider → .connect() → ConnectedProvider → .createTools() → registry
→ .buildContext() → system prompt
A context provider has two phases:
- Define — a factory function returns a
ContextProviderwith cheap metadata and aconnect()method - Connect —
connect()initializes auth and resources, returns aConnectedProviderwith tools
const exf = await connectProvider(
createExecuFunctionProvider({ token: "..." }),
registry,
);connectProvider() handles tagging: all tools are automatically tagged with "context" and "context:<providerId>" so agents can filter by provider or by the general context tag.
Providers can implement buildContext() to return a text block for system prompts:
const context = await contextPrompt([exf, obsidian]);
const systemPrompt = composePrompt({
role: "Personal assistant",
context,
toolGuide: autoToolGuide(registry),
});This is how agents get situational awareness (active tasks, upcoming events) without explicit tool calls. Context is best-effort — if a provider fails, the agent continues without that context.
Providers declare capability domains in their metadata:
tasks,projects,calendar,knowledge,people,organizations,codebase,vault,datasets,documents
This lets the framework and setup UX know what a provider offers without loading its tools. The ExecuFunction reference provider covers 7 of these domains with 17 tools.
checkProviderHealth() runs health checks across all connected providers, useful for setup UX and monitoring.
createStore() and createPgStore() provide persistence with the same interface. Tools can move from local JSON storage to Postgres with minimal code changes.
Memory is structured state for threads and facts. It is not the same as retrieval:
- conversation memory stores thread history
- fact memory stores extracted or saved facts
Memory becomes AI-usable through generated tools.
RAG is semantic retrieval over document chunks. It combines:
- chunking
- embeddings
- pgvector storage
- cosine similarity search
- optional tool exposure
See RAG.md for the full ingestion and search flow.
createChatAgent() is the highest-level composition primitive. It wires together an adapter, a registry, memory, and context providers into a single embeddable agent.
createChatAgent(config)
→ resolve adapter (provider + model)
→ build filtered registry (tools, tags, exclusions)
→ set up memory (conversation + facts → memory tools → registry)
→ connect context providers (tools + context prompt → registry + system prompt)
→ compose system prompt (preset/prompt/markdown + context + memory awareness)
→ return ChatAgent { chat(), interactive(), serve(), reset(), destroy() }
The factory is async because context providers require connect() calls.
agent.chat(message)— programmatic, returnsChatResult { text, toolCalls, rounds, metadata }agent.chat(message, { stream: true })— returnsAsyncIterable<ChatStreamChunk>with tool_call, tool_result, text, and done eventsagent.interactive()— readline CLI loopagent.serve({ port })— HTTP server with/chat,/chat/stream(SSE),/health,/reset
The config drives everything declaratively:
providerresolves to an adapter factory (auto-detected from env vars if omitted)preset/promptresolves to a system prompt (preset name, inline string, markdown file path, or structured PromptOptions)tools/toolTags/excludeToolsbuild a filtered registrymemory(default: on) creates conversation + fact memory and registers memory toolsprovidersresolves string names (e.g.,"execufunction") to ContextProvider instances via dynamic import
defineAgent() creates task-scoped agents for use inside crews and workflows. createChatAgent() creates session-scoped agents for direct user interaction. They use the same underlying primitives (registry, adapters, prompts) but serve different roles.
Use this order:
- write a tool
- register it
- execute it through MCP or chat
- use
createChatAgent()when you want a full agent with memory and context - compose multiple tools with workflows when the control flow is known
- use
defineAgent()inside crews when specialized agents need to collaborate - add memory or RAG only when state or retrieval is required
- add context providers when you need external systems (task management, calendars, CRM, etc.)
If you keep the runtime in mind, the rest of the framework stays predictable.