Native Tools
Native tools are built-in tools that run directly in your application, without requiring external MCP servers. They provide capabilities like web scraping, file processing, or custom integrations that are part of your core server.
Overview
Unlike MCP tools (which connect to external servers), native tools:
- Run in the same process as your server
- Have no external dependencies
- Are opt-in per agent (must be explicitly enabled)
- Use the
native:prefix in configuration
Available Native Tools
| Tool | Description | Widget |
|---|---|---|
web-scrape | Fetches a URL and returns the content as plain text. Supports HTML, JSON, and plain text responses. | No |
get-plan-usage | Returns the current user's plan information and usage statistics. | Yes |
Configuration
Native tools are enabled per-agent using the allowedTools configuration:
// config/app.config.ts
agent: {
agents: [
{
id: 'general',
name: 'General Assistant',
provider: 'openai',
model: 'gpt-4o-mini',
systemPrompt: 'You are a helpful assistant.',
maxTokens: 4096,
isDefault: true,
// No allowedTools = all MCP tools, but NO native tools
},
{
id: 'research-assistant',
name: 'Research Assistant',
provider: 'openai',
model: 'gpt-4o',
systemPrompt: 'You are a research assistant that can fetch and analyze web content.',
maxTokens: 8192,
// Enable native web-scrape tool + all tools from a specific MCP server
allowedTools: ['native:web-scrape', 'search-server:*'],
},
],
}
Tool Pattern Syntax
native:*- All native toolsnative:tool-name- Specific native tool (e.g.,native:web-scrape)mcp-server:*- All tools from an MCP servermcp-server:tool-name- Specific tool from an MCP server
Default Behavior
- No
allowedTools: Agent has access to all MCP tools but NO native tools - With
allowedTools: Agent only has access to tools matching the specified patterns
Creating New Native Tools
Native tools are defined in packages/server/src/tools/. Each tool is a module that exports a NativeTool object.
Step 1: Create the Tool File
Create a new file in packages/server/src/tools/:
// packages/server/src/tools/my-tool.ts
import type { NativeTool, ToolResult, ToolContext } from './types.js';
export const myTool: NativeTool = {
name: 'my-tool',
description: 'A description of what this tool does. This is shown to the LLM.',
inputSchema: {
type: 'object',
properties: {
param1: {
type: 'string',
description: 'Description of param1',
},
param2: {
type: 'number',
description: 'Description of param2',
default: 10,
},
},
required: ['param1'],
},
async execute(input: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
const param1 = input.param1 as string;
const param2 = (input.param2 as number) || 10;
try {
// Your tool logic here
const result = await doSomething(param1, param2);
return {
content: [{ type: 'text', text: result }],
};
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return {
content: [{ type: 'text', text: `Error: ${message}` }],
isError: true,
};
}
},
};
Step 2: Register the Tool
Add your tool to the registry in packages/server/src/tools/index.ts:
import { myTool } from './my-tool.js';
// Register built-in native tools
nativeToolRegistry.set('web-scrape', webScrapeTool);
nativeToolRegistry.set('my-tool', myTool); // Add this line
Step 3: Configure Agent Access
Enable the tool for agents that should have access:
// config/app.config.ts
{
id: 'my-agent',
name: 'My Agent',
// ...
allowedTools: ['native:my-tool'],
}
Type Definitions
NativeTool
interface NativeTool {
/** Unique tool name (used in allowedTools as 'native:name') */
name: string;
/** Human-readable description for the LLM */
description: string;
/** JSON Schema describing the tool's input parameters */
inputSchema: JSONSchema;
/** Optional metadata for UI templates and other extensions */
_meta?: NativeToolMeta;
/** Execute the tool with the given input */
execute: (input: Record<string, unknown>, context: ToolContext) => Promise<ToolResult>;
}
NativeToolMeta
Metadata for configuring tool widgets:
interface NativeToolMeta {
/** Inline HTML template for widget rendering */
'ui/template'?: string;
/** File path relative to tools/templates/ for widget rendering */
'ui/templateFile'?: string;
/** Additional metadata keys */
[key: string]: unknown;
}
ToolContext
The context object passed to every tool execution:
interface ToolContext {
userId?: string; // Current user's ID (if authenticated)
threadId?: string; // Current thread ID
agentId?: string; // Current agent ID
}
ToolResult
The result returned from tool execution:
interface ToolResult {
content: MCPContentItem[]; // Array of content items (text, images, etc.)
isError?: boolean; // Set to true if the tool encountered an error
structuredContent?: Record<string, unknown>; // Structured data for widget rendering
}
// Content item types
type MCPContentItem =
| { type: 'text'; text: string }
| { type: 'image'; data: string; mimeType: string }
| { type: 'resource'; resource: { uri: string; text?: string; blob?: string } };
Example: Web Scrape Tool
Here's the built-in web-scrape tool as a reference implementation:
// packages/server/src/tools/web-scrape.ts
export const webScrapeTool: NativeTool = {
name: 'web-scrape',
description: 'Fetches the content of a web page and returns it as plain text.',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'The URL of the web page to fetch',
},
maxLength: {
type: 'number',
description: 'Maximum characters to return (default: 50000)',
default: 50000,
},
},
required: ['url'],
},
async execute(input: Record<string, unknown>, _context: ToolContext): Promise<ToolResult> {
const url = input.url as string;
const maxLength = (input.maxLength as number) || 50000;
// Validate URL
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
return {
content: [{ type: 'text', text: `Invalid URL: ${url}` }],
isError: true,
};
}
// Only allow http/https
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return {
content: [{ type: 'text', text: `Invalid protocol: ${parsedUrl.protocol}` }],
isError: true,
};
}
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; ChatBot/1.0)',
},
});
if (!response.ok) {
return {
content: [{ type: 'text', text: `HTTP error: ${response.status}` }],
isError: true,
};
}
const html = await response.text();
const text = htmlToText(html); // Convert HTML to plain text
return {
content: [{ type: 'text', text: truncateText(text, maxLength) }],
};
} catch (error) {
return {
content: [{ type: 'text', text: `Failed to fetch: ${error.message}` }],
isError: true,
};
}
},
};
Widget Support
Native tools can render rich UI widgets instead of plain text output. This is useful for displaying structured data like charts, forms, or interactive components.
How Widgets Work
- Tool returns
structuredContentwith data for the widget - Tool specifies an HTML template via
_meta - Template is rendered in a sandboxed iframe
- Template accesses data via
window.openaiglobal object
Creating a Tool with a Widget
Step 1: Add _meta to Your Tool
Specify a template using either inline HTML or a file reference:
// Using a template file (recommended for complex widgets)
export const myTool: NativeTool = {
name: 'my-tool',
description: 'Does something useful',
inputSchema: { type: 'object', properties: {}, required: [] },
_meta: {
'ui/templateFile': 'my-tool.html', // Relative to tools/templates/
},
async execute(input, context) {
// ...
},
};
// Using inline HTML (for simple widgets)
export const simpleTool: NativeTool = {
name: 'simple-tool',
_meta: {
'ui/template': '<div id="output"></div><script>document.getElementById("output").textContent = JSON.stringify(window.openai.toolOutput);</script>',
},
// ...
};
Step 2: Return structuredContent
Your tool's execute function should return both content (for the LLM) and structuredContent (for the widget):
async execute(input, context): Promise<ToolResult> {
const data = await fetchSomeData();
return {
// Text content for the LLM to read
content: [{ type: 'text', text: `Found ${data.count} items` }],
// Structured data for the widget to render
structuredContent: {
count: data.count,
items: data.items,
timestamp: new Date().toISOString(),
},
};
}
Step 3: Create the HTML Template
Create your template file in packages/server/src/tools/templates/:
<!-- packages/server/src/tools/templates/my-tool.html -->
<!DOCTYPE html>
<html>
<head>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 16px;
/* Background is automatically set based on theme */
}
.card {
border-radius: 8px;
padding: 16px;
background: rgba(99, 102, 241, 0.1);
}
.card.dark {
background: rgba(129, 140, 248, 0.1);
}
</style>
</head>
<body>
<div id="card" class="card">
<div id="content">Loading...</div>
</div>
<script>
(function() {
// Access data from window.openai
const data = window.openai?.toolOutput || {};
const theme = window.openai?.theme || 'light';
// Apply theme
const card = document.getElementById('card');
if (theme === 'dark') {
card.classList.add('dark');
}
// Render content
document.getElementById('content').textContent = `Found ${data.count} items`;
})();
</script>
</body>
</html>
The window.openai API
Widgets have access to a global window.openai object with the following properties and methods:
window.openai = {
// Data
theme: 'light' | 'dark', // Current app theme
toolInput: { ... }, // Original tool arguments
toolOutput: { ... }, // structuredContent from tool result
locale: 'en-US', // Browser locale
maxHeight: 800, // Maximum render height
// Device info
userAgent: {
device: { type: 'desktop' | 'mobile' },
capabilities: { hover: boolean, touch: boolean }
},
// Methods
openExternal: ({ href }) => void, // Open URL in new tab
callTool: (name, args) => Promise, // Call another tool (stub)
sendFollowUpMessage: (args) => Promise, // Send message (stub)
};
Theme Support
The app automatically injects theme-appropriate styles into the iframe:
- Light mode: White background (
#ffffff) - Dark mode: Dark background (
#111827)
Your widget should check window.openai.theme and apply appropriate styles:
if (window.openai.theme === 'dark') {
document.body.classList.add('dark');
// Apply dark mode styles
}
Example: Plan Usage Widget
Here's the complete implementation of the get-plan-usage tool with a widget:
Tool (packages/server/src/tools/get-plan-usage.ts):
import { db } from '@chaaskit/db';
import { getConfig } from '../config/loader.js';
import { getBillingContext } from '../services/usage.js';
import type { NativeTool } from './types.js';
export const getPlanUsageTool: NativeTool = {
name: 'get-plan-usage',
description: 'Get the current user\'s plan information and usage statistics.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
_meta: {
'ui/templateFile': 'get-plan-usage.html',
},
async execute(input, context) {
const config = getConfig();
if (!config.payments.enabled) {
return {
content: [{ type: 'text', text: 'Payments are not enabled.' }],
structuredContent: {
planName: 'Free',
messagesUsed: 0,
messageLimit: null,
credits: null,
},
};
}
if (!context.userId) {
return {
content: [{ type: 'text', text: 'User not authenticated.' }],
isError: true,
};
}
const billingContext = await getBillingContext(context.userId);
if (!billingContext) {
return {
content: [{ type: 'text', text: 'Unable to retrieve billing info.' }],
isError: true,
};
}
const planConfig = config.payments.plans.find(p => p.id === billingContext.plan);
const planName = planConfig?.name || billingContext.plan;
return {
content: [{
type: 'text',
text: `Plan: ${planName}\nMessages: ${billingContext.messagesThisMonth}/${billingContext.monthlyLimit}`,
}],
structuredContent: {
planName,
planId: billingContext.plan,
billingContext: billingContext.type,
messagesUsed: billingContext.messagesThisMonth,
messageLimit: billingContext.monthlyLimit === -1 ? null : billingContext.monthlyLimit,
credits: billingContext.credits,
periodEnd: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1).toISOString(),
},
};
},
};
Template (packages/server/src/tools/templates/get-plan-usage.html):
<!DOCTYPE html>
<html>
<head>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 16px;
}
.card {
border-radius: 12px;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
max-width: 360px;
}
.card.dark {
background: linear-gradient(135deg, #434343 0%, #000000 100%);
}
.plan-name { font-size: 24px; font-weight: 700; margin-bottom: 4px; }
.usage-section {
background: rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 12px;
margin: 16px 0 12px;
}
.progress-bar {
height: 8px;
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
overflow: hidden;
margin-top: 8px;
}
.progress-fill {
height: 100%;
background: white;
border-radius: 4px;
}
</style>
</head>
<body>
<div id="card" class="card">
<div id="plan-name" class="plan-name">Loading...</div>
<div class="usage-section">
<div id="usage-text">-</div>
<div class="progress-bar">
<div id="progress" class="progress-fill" style="width: 0%"></div>
</div>
</div>
</div>
<script>
(function() {
const data = window.openai?.toolOutput || {};
const theme = window.openai?.theme || 'light';
if (theme === 'dark') {
document.getElementById('card').classList.add('dark');
}
document.getElementById('plan-name').textContent = data.planName || 'Unknown';
const used = data.messagesUsed || 0;
const limit = data.messageLimit;
if (limit) {
document.getElementById('usage-text').textContent = `${used} / ${limit} messages`;
document.getElementById('progress').style.width = Math.min((used / limit) * 100, 100) + '%';
} else {
document.getElementById('usage-text').textContent = `${used} messages (unlimited)`;
}
})();
</script>
</body>
</html>
Widget Best Practices
- Always provide
content: The LLM needs text content to understand the result - Keep templates self-contained: All styles and scripts should be inline
- Support both themes: Check
window.openai.themeand apply appropriate styles - Handle missing data: Your template should gracefully handle undefined values
- Use semantic HTML: Helps with accessibility and debugging
- Minimize JavaScript: Keep widget logic simple and fast
- Test both themes: Verify your widget looks good in light and dark mode
Best Practices
- Validate inputs: Always validate and sanitize user inputs before processing
- Handle errors gracefully: Return
isError: truewith a helpful message instead of throwing - Use timeouts: Set reasonable timeouts for external requests
- Respect rate limits: If your tool calls external APIs, implement rate limiting
- Keep descriptions clear: The LLM uses the description to decide when to use the tool
- Document parameters: Provide clear descriptions for each input parameter
Exposing Native Tools via MCP Server
When you enable the MCP server export feature, your native tools can be accessed by external MCP clients like Claude Desktop or MCP Inspector. This allows other applications to use your app's tools.
Configuration
// config/app.config.ts
mcp: {
server: {
enabled: true,
exposeTools: 'native', // Expose native tools via MCP
oauth: {
enabled: true,
allowDynamicRegistration: true,
},
},
}
Exposure Options
'native': Only expose native tools (recommended)'all': Expose native tools plus tools from connected MCP serversstring[]: Explicit list, e.g.,['web-scrape', 'get-plan-usage']
How It Works
- External MCP client connects to
/mcpendpoint - Client authenticates via OAuth or API key
- Client calls
tools/listto discover available tools - Client calls
tools/callto execute a tool - Tool runs with the authenticated user's context
User Context in External Calls
When a tool is called via the MCP server, the context object includes the authenticated user's ID:
async execute(input, context) {
// context.userId is set from the OAuth token or API key
if (!context.userId) {
return {
content: [{ type: 'text', text: 'Authentication required' }],
isError: true,
};
}
// Tool has access to user's data
const userData = await db.user.findUnique({ where: { id: context.userId } });
// ...
}
See MCP Integration > MCP Server Export for full documentation.
Security Considerations
- Native tools run with server privileges - be careful about what capabilities you expose
- Validate URLs to prevent SSRF attacks (e.g., block internal IPs)
- Sanitize file paths if your tool accesses the filesystem
- Consider rate limiting to prevent abuse
- Use the
context.userIdto implement per-user restrictions if needed - When exposing via MCP server, tools run with the authenticated user's permissions