HelpStackDocs

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 requiredGrowth and up. Creating or assigning custom tools returns 403 on Free and Starter
Tool typesSERVER_SIDE (HTTP call from HelpStack) or CLIENT_SIDE (run in the visitor's browser)
Managed viaDashboard + /api/agent-tools (org-level), /api/channels/[id]/agent-tools (channel-level)
AuthDashboard session (these are configured by your team)
Server tool timeout~10s, response capped at 128 KB
Client tool timeout~5s (socket round-trip)
LoggingEvery 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/import and POST /api/channels/[id]/agent-tools all 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:

FieldRequiredNotes
NameyesThe function name the LLM calls, e.g. get_order_status. Must be a valid function identifier
DescriptionyesThe LLM reads this to decide when to call the tool. Max ~2000 chars (validated). Be specific
URLyes (server-side)Must be HTTPS. SSRF-protected: a blocklist rejects internal/private network targets
MethodnoGET | POST | PUT | PATCH. Default POST
HeadersnoOptional JSON object. Can be encrypted/masked (use for API keys/tokens)
Parameters SchemayesA JSON Schema object describing the arguments the LLM fills in (parametersSchema)
TypeyesSERVER_SIDE or CLIENT_SIDE
ActiveToggle to enable/disable the tool without deleting it

Worked example — get_order_status (SERVER_SIDE)#

Tool definition

FieldValue
Nameget_order_status
TypeSERVER_SIDE
MethodPOST
URLhttps://api.YOURCOMPANY.com/orders/status
Headers{ "Authorization": "Bearer YOUR_API_TOKEN" } (store as encrypted header)
DescriptionLook 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-sideClient-side
Timeout~10s~5s
Response cap128 KB (refused, not truncated)
On failure/timeoutLogged; 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:

  1. The AI emits a tool call for a CLIENT_SIDE tool.
  2. The server emits tool:execute to the widget.
  3. The widget runs it and replies with tool:result (or tool:error).
  4. 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

FieldValue
Namewhere_is_the_user
TypeCLIENT_SIDE
DescriptionCheck 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

FieldValue
Namehighlight
TypeCLIENT_SIDE
DescriptionScroll 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

FieldValue
Nameread_section
TypeCLIENT_SIDE
DescriptionRead 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

FieldValue
Namefill_field
TypeCLIENT_SIDE
DescriptionPut 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_SIDE tool 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 result is 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.