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), CLIENT_SIDE (run in the visitor's browser) or CLIENT_ACTION (a "Show me" button the visitor taps) |
| 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, CLIENT_SIDE or CLIENT_ACTION |
| 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.
The customer's verified email (bound parameters)#
On an email channel, the address a message came from is the customer's identity. A SERVER_SIDE tool can ask HelpStack to fill one of its parameters with that address. A "look up my orders" tool then needs no order number, and nobody can talk the AI into looking up someone else's orders by typing their address.
Add "x-helpstack-bind": "conversation.senderEmail" to the parameter:
{
"type": "object",
"properties": {
"customer_email": {
"type": "string",
"x-helpstack-bind": "conversation.senderEmail"
},
"status": {
"type": "string",
"enum": ["open", "shipped", "delivered"],
"description": "Optional filter by order status"
}
},
"required": ["customer_email"]
}
What happens:
- The AI never sees the parameter. It is removed from the schema, and from
required, before the tool is offered. - HelpStack fills it on every call with the sender address of the email conversation, lowercased: in the query string for
GET, in the JSON body forPOST/PUT/PATCH, or in a{customer_email}placeholder in the URL. If the AI sends a value for it anyway, that value is thrown away. - The tool is only offered in email conversations. Website chat, Messenger, Instagram, WhatsApp and the other channels have no verified address, so the AI does not see the tool there and asks the customer for whatever your other tools need, such as an order number and email.
- Only
SERVER_SIDEtools and top-level parameters can be bound, andconversation.senderEmailis the only supported value. Anything else is rejected when you save.
Keep the lookup scoped on your side: return that customer's data and nothing else. Every reply goes to the same address the tool was called with, so what it returns reaches the owner of that mailbox.
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. An x-helpstack-bind extension on a parameter or on a property schema in the spec is kept (see the bound parameters section above).
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.
Replays are not logged. When HelpStack replays a past conversation to measure reply quality, a tool using GET (or HEAD) is called exactly as in a live reply, so your endpoint may see a lookup for an older message. A tool using any other method is never called during a replay, and replay calls do not appear in ToolCallLog.
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.
Action buttons (CLIENT_ACTION)#
A CLIENT_SIDE tool runs the moment the AI calls it. That is right for
reading the page, and wrong for anything the visitor has to see: on a phone
the open chat covers the whole page, so the page changes behind it and the
visitor misses it. Worse, the reply then says "I opened My team for you" about
something they never saw.
A CLIENT_ACTION tool is declared the same way (name, description, parameters
schema), but HelpStack never runs it when the AI calls it:
- The AI calls the tool. HelpStack checks the arguments against your schema (arguments you did not declare are dropped) and attaches a button to the reply. The AI is told the button was offered, so it writes "tap Show me", never "I opened".
- The visitor sees the reply with the button under it.
- When they tap it, the widget steps aside if it covers the page (phones, by default) and leaves a Back to chat pill at the bottom of the screen.
- Your page's handler runs. On success the pill stays until the visitor taps it. On failure the chat comes back with your message under the reply.
A reply carries at most 2 buttons. Buttons only exist in the web chat: on email, WhatsApp and the other channels the AI is not offered these tools.
Registering an action handler
<script>
window.HelpStack = window.HelpStack || function () {
(window.HelpStack.q = window.HelpStack.q || []).push(arguments);
};
window.HelpStack('registerAction', 'show_element', async ({ element }) => {
const el = document.querySelector(`[data-help="${element}"]`);
if (!el) return { ok: false, message: 'That part of the page is not here.' };
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('help-highlight');
return { ok: true };
});
</script>
- Return
{ ok: false, message }to report a failure; the visitor seesmessagein the chat and the AI reads it on its next turn. Returning anything else, or nothing, counts as success. A thrown error is a failure. - A handler gets 10 seconds. Navigation plus waiting for an element usually takes under 3.
- An existing
CLIENT_SIDEtool can be switched toCLIENT_ACTION(and back) in its edit form or withupdate_agent_tool; it keeps its name, parameters and call history. No other type change is possible. - The same name can be registered with
registerToolandregisterAction. The tool's type in HelpStack decides which one the AI gets. - If the page has not registered the handler (an old page, another site), the button is shown disabled.
- Buttons stay in the conversation and work again after a reload, as long as the page registers the handler again.
The button text
HelpStack adds an optional label parameter to every CLIENT_ACTION tool the
AI sees, so it can word the button in the reply language (at most 24
characters). Your handler never receives label. Without one the button says
"Show me" in the conversation language ("Pokaži mi", "Ukáž mi",
"Zeig es mir", ...).
What the widget does on tap
Set per channel under Channels → Appearance → When a visitor taps a "Show me" button:
| Setting | Phone (chat full screen) | Desktop (floating panel) |
|---|---|---|
| Step aside while full screen (default) | collapses to the pill | stays open |
| Always step aside | collapses to the pill | collapses to the pill |
| Stay open | stays open | stays open |
"Full screen" means what the visitor sees right now, not a guess from the browser name.
Widget API
These work on any page with widget.js, buttons or not:
ChatWidget.minimize(label?) | Close the panel and show the Back to chat pill |
ChatWidget.restore() | Reopen the chat where the visitor left it |
ChatWidget.isFullscreen() | true while the open chat covers the page |
ChatWidget.on('state', fn) | fn({ state: 'open' | 'minimized' | 'closed', fullscreen }); returns an unsubscribe function |
ChatWidget.registerAction(name, fn) | Same as HelpStack('registerAction', ...) once the script has loaded |
Logs
Each button writes up to four ToolCallLog rows: OFFERED when the AI
attached it, TAPPED when the visitor tapped it, then SUCCESS or FAILED
with your handler's message. A call HelpStack refused (bad arguments, a third
button) is logged as FAILED with the reason.