Integrations
Custom Agent Tools
Give the AI live access to your systems: define tools the LLM can call during reply generation to fetch order status, look up accounts, check inventory, and more.
Quick facts#
| Plan required | Growth and up. Creating or assigning custom tools returns 403 on Free and Starter |
| Tool types | SERVER_SIDE (HTTP call from HelpStack) or CLIENT_SIDE (run in the visitor's browser) |
| Managed via | Dashboard + /api/agent-tools (org-level), /api/channels/[id]/agent-tools (channel-level) |
| Auth | Dashboard session (these are configured by your team) |
| Server tool timeout | ~10s, response capped at 128 KB |
| Client tool timeout | ~5s (socket round-trip) |
| Logging | Every call recorded in ToolCallLog |
Check your plan before you build. Custom agent tools are a Growth plan and up feature. On Free or Starter,
POST /api/agent-tools,POST /api/agent-tools/openapi/importandPOST /api/channels/[id]/agent-toolsall return 403 with:Custom agent tools are available on the Growth plan and up. Upgrade to let the AI call your APIs.The gate is server-side, so it applies to the dashboard and the API equally. Nothing below works until the organization is on Growth or higher.
For the conceptual overview, see the Agent tools guide. This page is the technical reference.
Tool anatomy#
A tool definition has these fields:
| Field | Required | Notes |
|---|---|---|
| Name | yes | The function name the LLM calls, e.g. get_order_status. Must be a valid function identifier |
| Description | yes | The LLM reads this to decide when to call the tool. Max ~2000 chars (validated). Be specific |
| URL | yes (server-side) | Must be HTTPS. SSRF-protected: a blocklist rejects internal/private network targets |
| Method | no | GET | POST | PUT | PATCH. Default POST |
| Headers | no | Optional JSON object. Can be encrypted/masked (use for API keys/tokens) |
| Parameters Schema | yes | A JSON Schema object describing the arguments the LLM fills in (parametersSchema) |
| Type | yes | SERVER_SIDE or CLIENT_SIDE |
| Active | — | Toggle to enable/disable the tool without deleting it |
Worked example — get_order_status (SERVER_SIDE)#
Tool definition
| Field | Value |
|---|---|
| Name | get_order_status |
| Type | SERVER_SIDE |
| Method | POST |
| URL | https://api.YOURCOMPANY.com/orders/status |
| Headers | { "Authorization": "Bearer YOUR_API_TOKEN" } (store as encrypted header) |
| Description | Look up the current status and tracking info for a customer order by its order number. Call this whenever the customer asks where their order is, when it will arrive, or to confirm an order was placed. |
Parameters schema (JSON Schema)
{
"type": "object",
"properties": {
"order_number": {
"type": "string",
"description": "The customer's order number, e.g. ORD-10432"
}
},
"required": ["order_number"]
}
Request your endpoint receives
On a tool call, HelpStack sends the LLM-provided arguments as the request body (for POST/PUT/PATCH), with your configured headers plus Content-Type: application/json:
POST /orders/status HTTP/1.1
Host: api.YOURCOMPANY.com
Authorization: Bearer YOUR_API_TOKEN
Content-Type: application/json
{ "order_number": "ORD-10432" }
Response contract your endpoint must honor
Return JSON the model can read. The call times out at ~10s and the response is capped at 128 KB.
A response over the cap is refused, not truncated — half a JSON document either fails to parse or, worse, parses into a different meaning — so the call fails and the AI is told the tool is unavailable. The same happens if the body is not JSON at all, which is usually an HTML error page from a proxy.
The cap is a backstop against an endpoint running away (a paginated list with no page size, a debug dump), not a target to aim at. Size still costs you below it: whatever you return is parsed whole, written to the call log, and put in front of the model, so it is paid for in tokens on every reply that uses it. Return the few fields the AI needs to answer, not your full record — an order's status, ETA and tracking number, not the order object.
{
"status": "shipped",
"carrier": "DHL",
"tracking_number": "JD0140...",
"estimated_delivery": "2026-06-02"
}
The AI receives this payload as the tool result and weaves it into its reply. There is no required envelope — return whatever fields are useful, but make them self-describing so the model uses them correctly.
Writing good descriptions#
The description is the single most important field — it is the only thing the LLM uses to decide whether and when to call the tool.
- State what the tool returns and when to call it ("Call this when the customer asks ...").
- Mention the trigger phrases customers actually use.
- Describe each parameter clearly in its schema
description. - Keep under the ~2000 char limit (the save dialog validates and surfaces errors).
Security & SSRF protection#
- HTTPS only. Plain HTTP URLs are rejected.
- SSRF blocklist. Internal/private network targets (loopback, private RFC1918 ranges, link-local, metadata endpoints) are blocked so a tool cannot be pointed at internal infrastructure.
- Secret headers. Put API keys/tokens in Headers, which can be encrypted/masked rather than stored in plaintext.
- Treat the tool endpoint as internet-facing — authenticate requests (e.g. a bearer token in Headers) and validate input on your side.
Timeouts & caps#
| Server-side | Client-side | |
|---|---|---|
| Timeout | ~10s | ~5s |
| Response cap | 128 KB (refused, not truncated) | — |
| On failure/timeout | Logged; AI told the tool is unavailable and continues with the best reply it can |
Failures degrade gracefully — a broken or slow tool never blocks a reply; the AI simply proceeds without that data.
Org vs. channel tools + semantic filtering#
- Tools can be defined at the organization level and at the channel level.
- For a given conversation, org-level and channel-level tools are merged, and a channel tool overrides an org tool with the same name.
- The merged set is semantically filtered by relevance to the customer's query, so a large catalog of tools doesn't bloat every LLM call — only relevant tools are offered.
- Selected tools are converted to OpenAI/Anthropic function definitions and offered during reply generation.
OpenAPI import#
You can bootstrap tools from an existing API: paste or upload an OpenAPI 3.0 spec, and HelpStack extracts its operations into tool definitions you review and save. This is the fastest way to expose an existing REST API to the AI.
Logging#
Every tool call is recorded in ToolCallLog. View a tool's call history (arguments, outcome, timing) via:
GET /api/agent-tools/[id]/logs
Use the logs to debug why a tool was or wasn't called, and to spot timeouts/errors.
Client-side tools (advanced)#
CLIENT_SIDE tools are declared the same way (name, description, parameters schema) but execute in the website visitor's browser, mediated by the widget socket:
- The AI emits a tool call for a
CLIENT_SIDEtool. - The server emits
tool:executeto the widget. - The widget runs it and replies with
tool:result(ortool:error). - There is a ~5s timeout; on timeout the call degrades gracefully (tool unavailable).
Registering a handler
The widget script exposes window.HelpStack. Register a handler by name and it
is called whenever the AI invokes that tool:
<script src="https://helpstack.eu/widget.js?id=CHANNEL_ID" async></script>
<script>
window.HelpStack = window.HelpStack || function () {
(window.HelpStack.q = window.HelpStack.q || []).push(arguments);
};
window.HelpStack('registerTool', 'highlight', async ({ section }) => {
document.querySelector(`#${section}`)?.scrollIntoView({ behavior: 'smooth' });
return { highlighted: section };
});
</script>
The queue shim in the middle means you can register before widget.js has
loaded; the script drains the queue on startup. Whatever your handler returns is
what the AI reads, and if it throws, the AI is told the tool failed. The same
function is also on ChatWidget.registerTool(name, fn) once the script has
loaded.
Example handlers
Four patterns, in the order teams usually build them. Each shows the tool definition you enter in the dashboard and the handler you register on your page. They are deliberately small: a client-side tool should do one thing and return a short, plain object.
1. Read where the visitor is (no parameters)
The most useful first tool. It costs nothing and stops the AI guessing what the visitor is looking at.
Definition
| Field | Value |
|---|---|
| Name | where_is_the_user |
| Type | CLIENT_SIDE |
| Description | Check which page and section the customer is currently looking at. Call this before giving directions, so you can describe what is actually on their screen. |
{ "type": "object", "properties": {}, "required": [] }
Handler
window.HelpStack('registerTool', 'where_is_the_user', async () => ({
page: location.pathname,
title: document.title,
// Whatever "where they are" means in your UI: an open tab, a wizard step, a route.
section: document.querySelector('[data-active-section]')?.dataset.activeSection ?? null,
}));
2. Point at something on the page
Definition
| Field | Value |
|---|---|
| Name | highlight |
| Type | CLIENT_SIDE |
| Description | Scroll to and visually highlight a control or section on the customer's screen so they can see exactly what you mean. Use this instead of describing where something is. |
{
"type": "object",
"properties": {
"target": {
"type": "string",
"description": "What to highlight, in the customer's own words: a button label, a section name, or a menu item."
}
},
"required": ["target"]
}
Handler
window.HelpStack('registerTool', 'highlight', async ({ target }) => {
const el = document.querySelector(`[data-tour="${CSS.escape(target)}"]`);
if (!el) {
// Not found is a normal outcome, not a failure. Say so and let the AI recover.
return { found: false, target };
}
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('hs-highlight');
setTimeout(() => el.classList.remove('hs-highlight'), 3000);
return { found: true, target };
});
3. Read content back to the AI
Definition
| Field | Value |
|---|---|
| Name | read_section |
| Type | CLIENT_SIDE |
| Description | Read what is currently written in one section of the page, field by field, so you can answer about the customer's real content instead of guessing. |
{
"type": "object",
"properties": {
"section": { "type": "string", "description": "Section name as the customer would say it." }
},
"required": ["section"]
}
Handler
window.HelpStack('registerTool', 'read_section', async ({ section }) => {
const root = document.querySelector(`[data-section="${CSS.escape(section)}"]`);
if (!root) return { found: false, section };
const fields = {};
root.querySelectorAll('[data-field]').forEach((el) => {
// Keep it small — the result goes into the AI's context on every call.
fields[el.dataset.field] = (el.value ?? el.textContent ?? '').trim().slice(0, 500);
});
return { found: true, section, fields };
});
Return field keys the AI can quote back to you. That is what makes a write tool like the next one possible.
4. Write something for the visitor
The highest-value pattern and the one to be most careful with: the AI is now changing the visitor's screen.
Definition
| Field | Value |
|---|---|
| Name | fill_field |
| Type | CLIENT_SIDE |
| Description | Put suggested text into a specific field on the customer's page so they can review it and save it themselves. Only use a field key that read_section returned. Never save or submit on the customer's behalf. |
{
"type": "object",
"properties": {
"section": { "type": "string", "description": "Section name, as returned by read_section." },
"field": { "type": "string", "description": "Field key exactly as read_section returned it." },
"text": { "type": "string", "description": "The complete new contents of that field." }
},
"required": ["section", "field", "text"]
}
Handler
window.HelpStack('registerTool', 'fill_field', async ({ section, field, text }) => {
const el = document.querySelector(
`[data-section="${CSS.escape(section)}"] [data-field="${CSS.escape(field)}"]`
);
if (!el) return { written: false, reason: 'field not found', section, field };
el.value = text;
// Frameworks that track their own state need to be told.
el.dispatchEvent(new Event('input', { bubbles: true }));
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
return { written: true, section, field };
});
⚠️ Fill it in, do not submit it. Let the visitor read the change and press save themselves. A tool that publishes on their behalf turns a helpful suggestion into an unrecoverable action, and the AI cannot see the result.
Writing handlers that behave
- Return small, plain objects. The result is fed back into the AI's context
on every call. Return
{ found: true, section, fields }, never a DOM node, a component, or a 50 KB blob. - "Not found" is a result, not an error. Return
{ found: false }and let the AI say something sensible. Throwing produces a tool failure, and the AI only learns that something broke. - Stay well under the ~5s timeout. No network calls the visitor has to wait
for. If you need your backend, make it a
SERVER_SIDEtool instead. - Never expose anything the visitor cannot already see. A client-side tool runs on a public page with no authentication of its own. Do not read auth tokens, other customers' data, or anything behind your own permission checks.
- Name parameters in the customer's language, not your schema's. The AI
fills them from what the customer typed.
target: "the save button"works;elementId: "btn-save-primary"does not.
The underlying protocol
registerTool is a thin wrapper over postMessage, and you can answer the
messages yourself instead — useful if you already have a message router, or if
you want to serve a tool the widget does not know about.
⚠️ If you register a handler through window.HelpStack, do not also answer
that same tool with your own listener. The iframe resolves the first response
per callId, so two answers race. The widget stays silent for tools it has no
handler for, precisely so an existing custom listener keeps working.
The widget iframe posts every client-side call to its parent window:
{ type: 'TOOL_EXECUTE', callId: '…', toolName: 'highlight', parameters: { … } }
It then waits for one of these back, matched on callId:
{ type: 'TOOL_RESULT', callId: '…', result: { … } } // whatever the AI should read
{ type: 'TOOL_ERROR', callId: '…', error: 'why it failed' }
So a handler on your own page is all it takes:
window.addEventListener('message', (event) => {
// Required. Without this check any page in an iframe could drive your tools.
if (event.origin !== 'https://helpstack.eu') return;
const { type, callId, toolName, parameters } = event.data ?? {};
if (type !== 'TOOL_EXECUTE') return;
const reply = (result) =>
event.source.postMessage({ type: 'TOOL_RESULT', callId, result }, event.origin);
const fail = (error) =>
event.source.postMessage({ type: 'TOOL_ERROR', callId, error }, event.origin);
if (toolName === 'highlight') {
const el = document.querySelector(parameters.selector);
if (!el) return fail('No element matches that selector.');
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('my-highlight');
reply({ ok: true, message: 'Highlighted it. Tell the customer to look at the ring.' });
}
});
Three things worth knowing before you build on this:
- Answer within the 5s timeout, and answer honestly. On timeout the call
resolves to
{ success: false, error: 'timeout' }and the AI carries on without it. Do not reply before you know the action worked — an AI told "highlighted" when nothing was highlighted will send the customer looking for something that is not on their screen, which is worse than no tool. - Whatever you put in
resultis what the AI reads. A sentence explaining what happened gets you a better reply than{ ok: true }. - The visitor may not be on a page that can do the job. Return a result saying so, rather than failing silently, so the AI can explain instead.
Pair this with window.ChatWidget.identify(identity, metadata) so the AI knows
which account it is helping before it calls anything.