Building a Custom Agent
This guide walks through the entire process of building a custom specialist agent, from writing the MCP server, to registering it in oHallo, to testing it with a real conversation. By the end, you have an “Inventory Check Agent” that customers can ask about product stock levels.
The goal
Section titled “The goal”A customer writes: “Do you have the Model X-200 in stock? I need 50 units.”
The Orchestrator recognises this as an inventory question and hands it to your Inventory Check Agent, which calls your MCP server to check real-time stock levels and returns accurate availability information for the reply.
Step 1: Build the MCP server
Section titled “Step 1: Build the MCP server”Create a new project with two tools: check_stock (check availability for a specific product) and search_products (find products by name or category).
mkdir inventory-mcp && cd inventory-mcpnpm init -ynpm pkg set type=modulenpm install @modelcontextprotocol/server@2 @modelcontextprotocol/node@2 zod@4npm install -D typescript @types/node tsxCreate src/server.ts with version 2 of the official MCP TypeScript SDK, @modelcontextprotocol/server, as Build an MCP Server describes step by step, with the same tsconfig.json:
import { timingSafeEqual } from 'node:crypto'import { createServer } from 'node:http'import { toNodeHandler } from '@modelcontextprotocol/node'import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'import type { CallToolResult } from '@modelcontextprotocol/server'import * as z from 'zod'
// Simulated product inventory; replace with your real databaseconst inventory: Record<string, { sku: string name: string category: string stockQuantity: number warehouse: string unitPrice: number currency: string reorderLeadDays: number}> = { 'X-200': { sku: 'X-200', name: 'Model X-200 Industrial Sensor', category: 'sensors', stockQuantity: 120, warehouse: 'Rotterdam-NL', unitPrice: 84.50, currency: 'EUR', reorderLeadDays: 14, }, 'X-300': { sku: 'X-300', name: 'Model X-300 Precision Sensor', category: 'sensors', stockQuantity: 0, warehouse: 'Rotterdam-NL', unitPrice: 129.00, currency: 'EUR', reorderLeadDays: 21, }, 'C-100': { sku: 'C-100', name: 'Controller C-100 Base Unit', category: 'controllers', stockQuantity: 45, warehouse: 'Rotterdam-NL', unitPrice: 320.00, currency: 'EUR', reorderLeadDays: 28, }, 'A-50': { sku: 'A-50', name: 'Mounting Adapter A-50', category: 'accessories', stockQuantity: 500, warehouse: 'Rotterdam-NL', unitPrice: 12.00, currency: 'EUR', reorderLeadDays: 7, },}
// A tool's answer: the data as structuredContent, and the same data as JSON in the result's text item.function answer(data: Record<string, unknown>): CallToolResult { return { content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data }}
// A tool's error: a result marked isError whose text item is the JSON of the error.function toolError(error: { error_type: string; reason: string; [field: string]: string }): CallToolResult { return { isError: true, content: [{ type: 'text', text: JSON.stringify(error) }] }}
const price = z.object({ amount: z.number(), currency: z.string() })
function buildServer(): McpServer { const server = new McpServer({ name: 'inventory-checker', version: '1.0.0' })
server.registerTool( 'check_stock', { description: 'Check the current stock level for a specific product by SKU. Returns the available quantity, warehouse location, unit price, and reorder lead time. Use this when a customer asks about availability of a specific product. Return only the data provided by this tool.', inputSchema: z.object({ sku: z.string().describe('The product SKU code, e.g. X-200, C-100'), requiredQuantity: z .number() .int() .optional() .describe('The quantity the customer needs. Used to determine if stock is sufficient.'), }), outputSchema: z.object({ sku: z.string(), name: z.string(), category: z.string(), stockQuantity: z.number().int(), warehouse: z.string(), unitPrice: price, requestedQuantity: z.number().int(), available: z.boolean(), shortfall: z.number().int(), reorderLeadDays: z.number().int(), }), annotations: { readOnlyHint: true, openWorldHint: false }, }, async (args) => { const sku = args.sku.toUpperCase() const requiredQuantity = args.requiredQuantity ?? 1
const product = inventory[sku] if (!product) { return toolError({ error_type: 'not_found', reason: `Product with SKU "${sku}" not found in catalog` }) }
const sufficient = product.stockQuantity >= requiredQuantity
return answer({ sku: product.sku, name: product.name, category: product.category, stockQuantity: product.stockQuantity, warehouse: product.warehouse, unitPrice: { amount: product.unitPrice, currency: product.currency }, requestedQuantity: requiredQuantity, available: sufficient, shortfall: sufficient ? 0 : requiredQuantity - product.stockQuantity, reorderLeadDays: product.reorderLeadDays, }) }, )
server.registerTool( 'search_products', { description: 'Search the product catalog by name or category. Returns matching products with stock levels and pricing. Use this when a customer asks about available products or browses a category. Return only the data provided by this tool.', inputSchema: z.object({ query: z.string().optional().describe('Search term to match against product name or SKU'), category: z.enum(['sensors', 'controllers', 'accessories']).optional().describe('Filter by product category'), inStockOnly: z.boolean().optional().describe('If true, only return products with stock > 0'), }), outputSchema: z.object({ products: z.array( z.object({ sku: z.string(), name: z.string(), category: z.string(), stockQuantity: z.number().int(), inStock: z.boolean(), unitPrice: price, }), ), totalResults: z.number().int(), }), annotations: { readOnlyHint: true, openWorldHint: false }, }, async (args) => { const query = (args.query ?? '').toLowerCase()
let results = Object.values(inventory)
if (query) { results = results.filter( (p) => p.name.toLowerCase().includes(query) || p.sku.toLowerCase().includes(query) ) }
if (args.category) { results = results.filter((p) => p.category === args.category) }
if (args.inStockOnly) { results = results.filter((p) => p.stockQuantity > 0) }
return answer({ products: results.map((p) => ({ sku: p.sku, name: p.name, category: p.category, stockQuantity: p.stockQuantity, inStock: p.stockQuantity > 0, unitPrice: { amount: p.unitPrice, currency: p.currency }, })), totalResults: results.length, }) }, )
return server}
const mcpHandler = toNodeHandler(createMcpHandler(buildServer))
// True when the request carries the key in MCP_API_KEY as its Bearer token.function hasValidKey(authorization: string | undefined): boolean { const expected = process.env.MCP_API_KEY if (!expected || !authorization?.startsWith('Bearer ')) return false const given = Buffer.from(authorization.slice('Bearer '.length)) const wanted = Buffer.from(expected) return given.length === wanted.length && timingSafeEqual(given, wanted)}
const httpServer = createServer((req, res) => { if (new URL(req.url ?? '/', 'http://localhost').pathname !== '/mcp') { res.writeHead(404).end() return } if (!hasValidKey(req.headers.authorization)) { res.writeHead(401, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Unauthorized' }, id: null })) return } void mcpHandler(req, res)})
httpServer.listen(4300, () => { console.log('Inventory MCP server listening on port 4300')})Neither tool declares tenantId or workspaceId. oHallo adds both to the arguments of every call, and the SDK passes a handler only the arguments its input schema declares, so a tool that has no use for them leaves them out, as Tool Schema describes.
Test it locally:
MCP_API_KEY=dev-key npx tsx src/server.tsVerify tools are registered:
curl -s http://localhost:4300/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer dev-key" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | sed -n 's/^data: //p' | jq '.result.tools[].name'Test the check_stock tool:
curl -s http://localhost:4300/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer dev-key" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "check_stock", "arguments": { "sku": "X-200", "requiredQuantity": 50 } } }' | sed -n 's/^data: //p' | jq .result.structuredContentStep 2: Deploy and register in MCP Hub
Section titled “Step 2: Deploy and register in MCP Hub”Deploy your MCP server to a hosting provider that runs Node.js. oHallo reaches it over HTTPS on a public host name at the standard port, as Build an MCP Server describes. Set a strong MCP_API_KEY in your production environment.
Register in oHallo:
- Go to MCP hub in the sidebar.
- Select Add MCP server, then Add custom on the Custom MCP server card.
- Enter:
- Name: Inventory Checker
- Endpoint URL:
https://inventory.example.com/mcp - Auth type: API key, with Single API key as the credential shape: the key you set in
MCP_API_KEY
- Select Test & continue. oHallo calls
tools/listand lists your two tools. - Leave both tools unassigned and select Confirm: Step 3 creates the agent and gives it both tools.
Step 3: Create the custom agent
Section titled “Step 3: Create the custom agent”Create a specialist agent via the API, with the inventory tools and a system prompt that guides its behaviour. First find the id of the Inventory Checker connection: GET /api/mcp-connections lists your connections, each with its connectionId, and takes the mcp:read scope. scope=tenant-built leaves out the connections oHallo provides itself.
curl -s "https://api.ohallo.eu/api/mcp-connections?scope=tenant-built" \ -H "Authorization: Bearer sf_live_v1_your_key_here" | jq '.[] | {connectionId, name}'A custom agent is created with a displayName, a description (the dispatch signal the Orchestrator reads), a systemPrompt, and its MCP connections in mcpConnections: at least one, each with the tools the agent may call on it. Creating an agent takes the agents:write and mcp:write scopes. The agent has no workspace of its own: the assistant hands it tasks in every workspace where its connections are active. The model is optional: an id from GET /api/agent-definitions/available-models, and without one the agent runs on Automatic, where oHallo chooses the model.
curl -s -X POST https://api.ohallo.eu/api/agent-definitions/custom \ -H "Authorization: Bearer sf_live_v1_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "displayName": "Inventory Check Agent", "description": "Checks product availability and stock levels. Called when customers ask about product availability, stock, or delivery timelines for specific products.", "systemPrompt": "You are an inventory specialist. When asked about product availability, use the check_stock tool with the product SKU. If the customer does not provide a SKU, use search_products to find matching items first. Always include the unit price and stock quantity in your response. If a product is out of stock, mention the reorder lead time.", "mcpConnections": [ { "connectionId": "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b", "allowedTools": ["check_stock", "search_products"] } ] }' | jq .Response (201 Created, trimmed to the main fields):
{ "id": "7c3f9a1e-2b4d-4c8a-9f6e-1a2b3c4d5e6f", "displayName": "Inventory Check Agent", "description": "Checks product availability and stock levels...", "agentRole": "specialist", "model": null, "createdAt": "2026-03-25T12:00:00.000Z"}"model": null is Automatic. The agent now holds check_stock and search_products, and Step 4 makes them callable.
Step 4: Classify and declare the tools
Section titled “Step 4: Classify and declare the tools”The agent calls a tool once the tool has a risk classification and a declaration of whose records it returns.
- On the connection’s page, in the Tools table, set the Risk of
check_stockandsearch_productstoread. Both declarereadOnlyHint: true, so they start as read (PII); they return no personal data. The lowering is confirmed on a panel. - Follow the Customer records link. Under Customer data, select Set up beside each tool, choose General information, for anyone under What this tool returns, and select Save.
Step 5: Test with a real conversation
Section titled “Step 5: Test with a real conversation”Send a test message to one of your oHallo email or chat channels. For example:
“Hi, I need to order 50 units of the Model X-200. Can you check if they are in stock?”
Here is what happens behind the scenes:
- The Orchestrator reads the message and identifies the intent: inventory check for a specific product.
- It hands the request to the Inventory Check Agent because its description matches (“Checks product availability and stock levels”).
- The Inventory Check Agent calls
check_stockwithsku: "X-200"andrequiredQuantity: 50. - Your MCP server returns: 120 in stock, available, unit price EUR 84.50.
- The Message Agent composes the response. A reply might read:
“The Model X-200 Industrial Sensor is in stock. We currently have 120 units available in our Rotterdam warehouse, which covers your request for 50 units. The unit price is EUR 84.50.”
Step 6: Monitor agent executions and tool calls
Section titled “Step 6: Monitor agent executions and tool calls”After testing, verify that everything worked correctly.
View in the dashboard:
- Open the test conversation under Conversations.
- Select Inspect on the assistant’s reply.
- The panel lists Inventory Check Agent with the number of tools it called and how long it took. Select it to see the
check_stockcall, and the call to see the arguments it was sent and the response it received.
View via the API:
Agent executions are queried per conversation, with the conversations:read scope. The conversationId query parameter is required; without it the endpoint returns 400. The response is a bare JSON array of executions.
curl -s "https://api.ohallo.eu/api/agent-executions?conversationId=c9f8e7d6-b5a4-3210-fedc-ba9876543210" \ -H "Authorization: Bearer sf_live_v1_your_key_here" | jq .Response (trimmed to the main fields):
[ { "id": "8a1b2c3d-4e5f-4061-8273-90abcdef1234", "agentId": "7c3f9a1e-2b4d-4c8a-9f6e-1a2b3c4d5e6f", "agentName": "Inventory Check Agent", "agentRole": "specialist", "status": "completed", "toolCallCount": 1, "durationMs": 3120, "createdAt": "2026-03-25T15:00:00.000Z" }]Tool calls are a separate endpoint, with the same scope. Pass either the conversationId (to flatten every tool call across the conversation) or an executionId (to narrow to one execution); a key restricted to a brand or a workspace passes the conversationId. The response is a bare JSON array.
curl -s "https://api.ohallo.eu/api/tool-calls?conversationId=c9f8e7d6-b5a4-3210-fedc-ba9876543210" \ -H "Authorization: Bearer sf_live_v1_your_key_here" | jq .Response (trimmed to the main fields):
[ { "id": "5f6e7d8c-9b0a-4132-8546-a1b2c3d4e5f6", "toolName": "check_stock", "input": { "sku": "X-200", "requiredQuantity": 50 }, "output": { "sku": "X-200", "name": "Model X-200 Industrial Sensor", "stockQuantity": 120, "available": true }, "error": null, "policyDecision": "allow", "durationMs": 180 }]Check for:
- Execution status:
completedmeans the agent ran successfully.failedmeans something went wrong. - Tool call error: a null
errormeans the MCP server responded correctly. A populatederrormeans it returned an error or timed out. - Duration: Tool calls must answer within 10 seconds (Tool call timeout gives the limits). If they are slow, optimise your MCP server’s data access.
- Input: Verify the agent extracted the correct values from the customer’s message.
Summary
Section titled “Summary”The steps to build a custom agent are:
- Build an MCP server with the tools the agent needs.
- Deploy the server and register it in oHallo’s MCP Hub.
- Create the agent with a clear description, a system prompt, and the MCP connection with the tools it may call.
- Classify and declare each tool, so the agent can call it.
- Test with a real message and monitor the execution logs.
You can repeat this pattern for any domain: order management, warranty claims, appointment scheduling, quoting, or anything else your business handles.
Next steps
Section titled “Next steps”- Testing and Debugging, debug tool calls and common issues
- Tool Schema, advanced input schema patterns
- Managing the Knowledge Base, add static knowledge alongside your live tools