Custom Tools

Custom Tools let your agent call your own API mid-conversation - order status, account lookups, availability checks, bookings, or any workflow your backend already exposes. The agent decides when a tool is needed, collects the inputs from the conversation, calls your endpoint, and works the result into its reply.

Overview#

Your knowledge base answers questions about your business. Custom Tools answer questions about this customer - things that live in your database and change by the minute. Find them at Dashboard → Agent → Tools.

Splice stores only the tool definition and, optionally, one encrypted credential. Your business data stays on your side: it is fetched at the moment it is needed, used for that reply, and never mirrored into Splice.

You configureSplice handles
The endpoint, method and authSigning every request, refreshing OAuth tokens, HTTPS and SSRF checks
The inputs your API needs, in plain EnglishDeciding when the tool is relevant and collecting those inputs from the conversation
Which channels may use it, and whether the caller must be verifiedProving who the caller is with a signed identity token, and blocking cross-account lookups
Optionally, how the response maps onto a cardRendering it in chat, and polling long-running jobs to completion

When a tool actually runs#

Tools are not called on every message. Each inbound message goes through a fast selection step that decides whether any tool is needed, runs at most three rounds of calls, and then hands the results to the model that writes the reply.

  • Greetings, pricing questions, "what do you do" and anything the knowledge base can answer use no tool at all.
  • Only values the customer actually gave - or the verified identity Splice already holds - are used as arguments. Placeholders and examples from your description are never sent.
  • If two tools look equally plausible, or a required input is missing, no tool runs and the agent asks a follow-up question instead.
  • Tool selection runs in parallel with knowledge-base retrieval, so an enabled tool doesn't slow down replies that don't need one.

The description is the trigger

The name and description are the entire basis on which the agent decides to call a tool. "Order lookup" is weak. "Fetch the live status, carrier and ETA of one order by its order number" is what makes it fire at the right moment.

Building a tool, step by step#

The form is six numbered sections. Only the first three are mandatory - the rest have working defaults.

1 · Basics

A snake_case name (lowercase, 2-49 characters, fixed after creation), the description above, the HTTP method, and the endpoint URL. The URL must be public HTTPS and may contain {curly} placeholders, e.g. https://api.yourapp.com/orders/{order_id}.

2 · Authentication

HMAC signature, bearer token, OAuth client credentials, or none - covered in full under Authentication. Secrets are write-only: encrypted on save, never shown again, replaced only by typing a new one.

3 · What info does the agent need?

One row per input: field name, type, where it goes in the request, a plain-English hint, and whether it is required. See Parameters.

4 · How does your API respond?

Instant, or a job that Splice polls until it completes. See Responses & jobs.

5 · Show the answer as a card

Optional. Paste a sample response and map its fields onto a card. See Result cards.

6 · Access & channels

Which channels may use the tool, whether the caller must be verified, whether to forward the identity JWT, and the timeout. See Channels and Identity JWT.

Save, then use the row actions to test it, pause it without deleting it, or delete it - which asks you to type the tool name to confirm.

Importing an existing API#

"New tool" opens three shortcuts before the blank form, so you rarely start from nothing:

  1. 1Starter templates for the common shapes - look something up, book or schedule, update a record, create a ticket - which prefill the name, method, URL shape and fields for you to edit.
  2. 2Paste a cURL command you already have. Splice reads the URL, method, headers (detecting bearer or HMAC auth) and JSON body keys, and fills the form in.
  3. 3Import an OpenAPI spec: paste the JSON and Splice lists every operation it found, using the operation id as the tool name and its parameters and request body as the fields. Pick one to review and save.

Paste the spec, don't link it

The importer reads a spec you paste. It does not fetch a remote spec URL. It also creates one tool per operation you pick, not all of them at once - each still needs its auth and channels set.

Authentication, method by method#

Auth is how your API knows a request really came from your agent and not from someone who guessed the URL. Pick one of four modes in step 2 of the form. Whatever you paste is encrypted at rest and never shown again - editing a tool leaves the field blank, and submitting it blank keeps the stored value.

ModeWhat Splice sendsUse it when
HMAC signatureX-Splice-Timestamp + X-Splice-SignatureYou control the endpoint and can add ~15 lines of verification. Strongest option: the signature covers the body, so nobody can replay or tamper with a call.
Bearer tokenAuthorization: Bearer <token>Your API already takes a long-lived API key or service token.
OAuth (client credentials)Authorization: Bearer <fetched token>Your API issues short-lived machine tokens from a token endpoint.
NoneNothingThe endpoint is genuinely public and returns no private data.

Every request, whatever the auth mode

Splice always adds X-Splice-Tool with the tool's name, and Content-Type: application/json whenever there is a body. A GET or HEAD call never carries a body. A DELETE carries one only if it has body parameters.

POST /tools/order-status HTTP/1.1
Host: api.yourapp.com
X-Splice-Tool: get_order_status
X-Splice-Timestamp: 1786000000
X-Splice-Signature: sha256=6f1c…                (HMAC mode)
X-Splice-Identity: eyJhbGciOiJIUzI1NiIs…        (if "Forward identity JWT" is on)
Content-Type: application/json

{"order_id":"ORD-4821"}

HMAC signature

The secret is a string you invent (32+ random characters is a good default) and store on both sides. Splice signs HMAC-SHA256(secret, "{timestamp}.{raw body}") and sends it hex-encoded with a sha256= prefix. The timestamp is unix seconds and travels in its own header, so you can reject stale replays - we suggest a 5-minute window.

import express from "express";
import crypto from "crypto";

const app = express();
// The signature covers the EXACT bytes we sent, so verify before JSON parsing.
app.use(express.raw({ type: "application/json" }));

app.post("/tools/order-status", (req, res) => {
  const raw = req.body.toString("utf8");          // "" on GET / bodyless calls
  const ts = req.get("X-Splice-Timestamp") ?? "";
  const got = req.get("X-Splice-Signature") ?? "";

  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
    return res.status(401).json({ error: "stale timestamp" });
  }

  const expected =
    "sha256=" +
    crypto
      .createHmac("sha256", process.env.SPLICE_TOOL_SECRET)
      .update(`${ts}.${raw}`)
      .digest("hex");

  const a = Buffer.from(got);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).json({ error: "bad signature" });
  }

  const args = raw ? JSON.parse(raw) : {};
  res.json({ order: { id: args.order_id, status: "Shipped" } });
});

Sign the raw bytes, not a re-serialised object

Verifying against JSON.stringify(req.body) will fail intermittently - key order and spacing differ from what was signed. Capture the raw body first. On bodyless calls (GET, HEAD, argument-free DELETE, and every polling request) the signed string is just "{timestamp}." with an empty body.

Bearer token

Paste a token your API already accepts and Splice sends it verbatim as Authorization: Bearer <token>. There is no refresh: a token that expires will start failing with your own 401 and the agent will tell the customer the lookup failed. Use OAuth for anything short-lived. Minimum length is 8 characters.

OAuth (client credentials)

For machine-to-machine APIs. You give Splice the token URL, client ID, client secret, and optional space-separated scopes. Before a call, Splice POSTs a form-encoded client_credentials grant, caches the returned access_token encrypted, and reuses it until it expires.

POST https://auth.yourapp.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=…&client_secret=…&scope=api.read api.write

# expected response
{ "access_token": "…", "expires_in": 3600 }
  • Tokens are refreshed 60 seconds before expiry. If the response omits expires_in, Splice assumes one hour.
  • Concurrent calls share one in-flight token fetch, so a busy tool never stampedes your token endpoint.
  • The token URL is subject to the same HTTPS-only, no-private-address rules as the endpoint itself.

Who is asking: the identity JWT#

Tool arguments are filled in by a language model reading the conversation, so a customer could type someone else's order number. Never authorize on the arguments alone. Forward identity JWT gives you something you can trust instead: a short-lived HS256 token in the X-Splice-Identity header, signed with the same secret as your auth mode, valid for 5 minutes.

import jwt from "jsonwebtoken";

// Signed with the tool's own credential: the HMAC secret, the bearer token,
// or the OAuth client secret - whichever the tool is configured with.
const claims = jwt.verify(req.get("X-Splice-Identity"), process.env.SPLICE_TOOL_SECRET, {
  algorithms: ["HS256"],
  issuer: "spliceai",
});

if (!claims.verified) return res.status(403).json({ error: "unverified caller" });

// Authorize on the CLAIM, never on the arguments the model filled in.
const order = await db.orders.findForCustomer(claims.email);

The decoded payload looks like this:

{
  "iss": "spliceai",
  "iat": 1786000000,
  "exp": 1786000300,               // always iat + 300 seconds
  "sub": "lead_9f2c…",             // Splice's stable id for this end user
  "email": "priya@acme.com",
  "name": "Priya",
  "phone": "+919876543210",
  "workspaceId": "ws_…",
  "channel": "website",            // website | email | whatsapp | instagram | voice
  "verified": true,
  "identitySource": "widget_otp"
}
ClaimMeaning
subSplice's stable id for this end user (visitor, lead, or caller).
email / phone / nameWhatever the channel knows about them. Any may be null.
workspaceIdThe Splice workspace the tool belongs to.
channelwebsite, email, whatsapp, instagram, or voice.
verifiedTrue only when the channel proved the identity - see the channels table below.
identitySourceHow it was proved: widget_otp, widget_unverified, email_from, whatsapp_phone, voice_caller_id, or voice_collected.
iss / iat / expAlways "spliceai", issued-at, and issued-at + 300 seconds.

Requires a secret

The JWT is signed with your tool credential, so it cannot be enabled on auth type "None". Switching a tool to None turns it off automatically.

One exception on polling requests

Background polls for long-running jobs run after the conversation turn has ended and mint the JWT with channel: "poll", identitySource: "poll", and sub set to the conversation id. Authorize your polling endpoint on the job id in the URL, not on those claims.

"Only run for verified users"

Turn this on for anything account-specific. It does three things, all enforced server-side before your endpoint is ever called:

  • The tool is hidden entirely from the model on any conversation where the caller isn't verified - so it can't be tricked into calling it.
  • Empty email / phone parameters are auto-filled from the verified identity, so "what's my order status?" works without the customer retyping their address.
  • Any argument that looks like a different email address or phone number is refused before the request goes out, and the agent tells the customer it can only access their own account.

On the website widget, enabling it makes the chat widget ask visitors to verify their email by OTP before they can use that tool.

Parameters: what the agent collects#

Each field you add becomes one argument the model fills from the conversation. The What it's for column is not decoration - it is the only thing telling the model which piece of the conversation belongs in that field, so write it like a hint to a new colleague ("the customer's order number, like ORD-4821").

ColumnOptionsNotes
TypeText, Number, Yes/No, DateModels emit everything as text. Splice coerces numbers and booleans back before sending, so a strict endpoint won't reject "3".
InBody, Path, Query, HeaderWhere the value lands in the HTTP request. A URL placeholder like {order_id} is treated as a path parameter even if you forget to set this.
ReqOn / offIf a required value is missing, the tool is not called at all - the agent asks the customer for it instead, and never invents one.

Under the hood that becomes a JSON Schema, which is what an OpenAPI import produces too:

{
  "type": "object",
  "properties": {
    "order_id":  { "type": "string",  "description": "The customer's order number", "x-in": "path" },
    "include":   { "type": "string",  "description": "Extra sections to return",    "x-in": "query" },
    "notify":    { "type": "boolean", "description": "Email the customer a copy" }
  },
  "required": ["order_id"]
}

Empty values are dropped

Blank arguments are stripped before the request is built, so your API sees an absent field rather than an empty string.

Responses and long-running jobs#

Most tools answer right away: return JSON with a 2xx and the agent works it into its reply. Anything non-JSON comes back to the model as { result: "…" }. Responses are read up to 64KB and each call is bounded by the tool's timeout (1000-15000 ms, default 8000).

Pick "Starts a job, then I check back" when your API answers with a ticket instead of an answer. You then tell Splice four things: which field holds the job id, the URL to check (with {value} where the id goes), which field carries the status, and the value that means finished.

# 1. the agent calls your tool
POST https://api.yourapp.com/scans        ->  { "id": "job_812", "status": "queued" }

# 2. Splice polls, substituting {value} with the id at "id"
GET  https://api.yourapp.com/scans/job_812 -> { "status": "running" }
GET  https://api.yourapp.com/scans/job_812 -> { "status": "complete", "score": 82, … }
#                                               ^ pollStatusPath == pollDoneValue -> done
  • Splice polls up to 8 times at 1-second intervals inside the live reply, so a job that finishes in a few seconds is answered in the same message.
  • If it's still running, the agent replies with what it has, and a background worker keeps polling every 4 seconds for up to 5 minutes.
  • When the job finishes late, a short follow-up message is posted into the same conversation automatically - the widget receives it live, and it appears in the inbox.
  • Polling requests are GETs carrying the same auth headers, signed over an empty body.

Showing the answer as a card#

Optional. Paste one real response from your API, and the form flattens it into a list of dot-paths you can map onto a card: title, subtitle, a big number with a label, and a list of items pulled from an array. A live preview updates as you pick. Leave it blank and the agent just answers in prose.

  • The mapping is stored as paths, not values, and replayed deterministically against every future response - no second LLM call at message time.
  • Lists are capped at 8 items and badges at 4.
  • When a turn runs more than one tool, the card comes from the last one.
  • A card only renders if its title path resolves to a non-empty, non-boolean value. Otherwise the reply falls back to plain text.

Channels and what counts as verified#

A tool only loads on the channels you tick. Verification is decided by the channel, not by the tool - this table is what drives the verified claim and the "Only run for verified users" gate.

ChannelIdentity Splice hasCounts as verified?
Website widgetVisitor email confirmed by OTPYes, once the visitor verifies
EmailThe sender address on the inbound mailYes, when a sender is present
WhatsAppThe sender's phone numberYes, when a number is present
VoiceCarrier caller ID on inbound callsInbound calls with a number: yes. Outbound campaign calls: no.

Tools can be scoped to one agent

Created from Agent → Tools, a tool is available to every agent in the workspace. Created from an individual agent's Tools tab, it only loads for that agent.

Testing a tool#

The Test call button on any row fires the real request against your live endpoint - real auth, real signature, real identity JWT, real SSRF checks - and prints the raw response. No model is involved, so you type the arguments yourself as JSON.

There is no sandbox

A test call is a production call. If your endpoint has side effects, point it at test data while you are wiring things up.

In a test the caller is treated as verified with your own dashboard user as the identity and channel: "test", so identity-gated tools are reachable. Once it works, use the Playground or the widget to check the agent actually decides to call it - a tool that tests green but never fires almost always has a vague description.

Security model#

  • HTTPS only. Private, loopback, link-local and carrier-NAT addresses are rejected, as are .internal and .local hostnames - at save time and again at call time, after DNS resolution, so a hostname that later points at an internal IP still fails.
  • Redirects are never followed. A redirecting endpoint fails closed rather than silently changing destination.
  • Secrets (HMAC secret, bearer token, OAuth client secret, cached access tokens) are encrypted at rest and never returned by the API or redisplayed in the dashboard.
  • Responses are truncated at 64KB and every call is bounded by the tool timeout, capped at 15 seconds.
  • Every call writes an audit log entry with the tool name, HTTP status and duration - never the arguments or the response body.
  • Tools are workspace-scoped. Creating, editing and testing all require agent update permission.
  • The model may run at most 3 rounds of tool calls per message, one tool per round, and repeated identical calls are suppressed.

Splice stores the tool definition and the credential, never your business data. Responses are used to compose the reply and, where the conversation is stored, kept with that conversation.

Make write tools idempotent

The suppression above only covers one message. The model decides per message whether a tool is still needed, so a customer repeating their details later in the same conversation can trigger a second call. For anything that creates or pushes data (a CRM lead, an order), make your endpoint idempotent - upsert on a natural key like phone or email rather than always inserting a new row.

Troubleshooting#

What you seeWhat it meansFix
Tool returned 401.Your API rejected the credential or signature.For HMAC, check you signed the raw body with the timestamp header prefix. For bearer, check the token hasn't expired.
This tool's endpoint is not reachable.The URL failed a security check.Must be public HTTPS, not an IP literal, not a private or .internal / .local host.
The tool could not be reached.Timeout, DNS, TLS or network failure.Raise the timeout, or check the endpoint from outside your VPC.
OAuth token fetch failed.The token endpoint returned non-2xx or no access_token.Verify client id, secret, scopes, and that the grant is client_credentials.
The agent never calls the toolIt didn't judge the tool relevant, a required argument was missing, the channel isn't ticked, or the tool is off.Sharpen the description and the field hints, and check the Channels and Status columns.
It refuses and mentions the customer's own account"Only run for verified users" blocked an argument naming a different person.Expected behaviour. Turn the setting off only if the endpoint is not account-scoped.
Reply says the answer is still processingA job-mode tool didn't finish inside the live window.Nothing to do - the follow-up message arrives automatically, up to 5 minutes later.

Wiring checklist

  1. 1Endpoint reachable over public HTTPS and returns JSON.
  2. 2Signature or token verified server-side, with a timestamp window on HMAC.
  3. 3Authorization based on the X-Splice-Identity claims, not on the arguments.
  4. 4Test call green from the dashboard.
  5. 5Channels ticked, and the tool toggled Active.
  6. 6One real conversation in the Playground or widget that makes the agent call it.