Skip to content

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.

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.

Create a new project with two tools: check_stock (check availability for a specific product) and search_products (find products by name or category).

Terminal window
mkdir inventory-mcp && cd inventory-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server@2 @modelcontextprotocol/node@2 zod@4
npm install -D typescript @types/node tsx

Create 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 database
const 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:

Terminal window
MCP_API_KEY=dev-key npx tsx src/server.ts

Verify tools are registered:

Terminal window
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:

Terminal window
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.structuredContent

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:

  1. Go to MCP hub in the sidebar.
  2. Select Add MCP server, then Add custom on the Custom MCP server card.
  3. 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
  4. Select Test & continue. oHallo calls tools/list and lists your two tools.
  5. Leave both tools unassigned and select Confirm: Step 3 creates the agent and gives it both tools.

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.

Terminal window
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.

Terminal window
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.

The agent calls a tool once the tool has a risk classification and a declaration of whose records it returns.

  1. On the connection’s page, in the Tools table, set the Risk of check_stock and search_products to read. Both declare readOnlyHint: true, so they start as read (PII); they return no personal data. The lowering is confirmed on a panel.
  2. 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.

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:

  1. The Orchestrator reads the message and identifies the intent: inventory check for a specific product.
  2. It hands the request to the Inventory Check Agent because its description matches (“Checks product availability and stock levels”).
  3. The Inventory Check Agent calls check_stock with sku: "X-200" and requiredQuantity: 50.
  4. Your MCP server returns: 120 in stock, available, unit price EUR 84.50.
  5. 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:

  1. Open the test conversation under Conversations.
  2. Select Inspect on the assistant’s reply.
  3. The panel lists Inventory Check Agent with the number of tools it called and how long it took. Select it to see the check_stock call, 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.

Terminal window
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.

Terminal window
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: completed means the agent ran successfully. failed means something went wrong.
  • Tool call error: a null error means the MCP server responded correctly. A populated error means 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.

The steps to build a custom agent are:

  1. Build an MCP server with the tools the agent needs.
  2. Deploy the server and register it in oHallo’s MCP Hub.
  3. Create the agent with a clear description, a system prompt, and the MCP connection with the tools it may call.
  4. Classify and declare each tool, so the agent can call it.
  5. 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.