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.
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 configure | Splice handles |
|---|---|
| The endpoint, method and auth | Signing every request, refreshing OAuth tokens, HTTPS and SSRF checks |
| The inputs your API needs, in plain English | Deciding when the tool is relevant and collecting those inputs from the conversation |
| Which channels may use it, and whether the caller must be verified | Proving who the caller is with a signed identity token, and blocking cross-account lookups |
| Optionally, how the response maps onto a card | Rendering it in chat, and polling long-running jobs to completion |
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.
The description is the trigger
The form is six numbered sections. Only the first three are mandatory - the rest have working defaults.
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}.
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.
One row per input: field name, type, where it goes in the request, a plain-English hint, and whether it is required. See Parameters.
Instant, or a job that Splice polls until it completes. See Responses & jobs.
Optional. Paste a sample response and map its fields onto a card. See Result cards.
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.
"New tool" opens three shortcuts before the blank form, so you rarely start from nothing:
Paste the spec, don't link it
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.
| Mode | What Splice sends | Use it when |
|---|---|---|
| HMAC signature | X-Splice-Timestamp + X-Splice-Signature | You 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 token | Authorization: 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. |
| None | Nothing | The endpoint is genuinely public and returns no private data. |
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"}
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
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.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.
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 }
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"
}| Claim | Meaning |
|---|---|
| sub | Splice's stable id for this end user (visitor, lead, or caller). |
| email / phone / name | Whatever the channel knows about them. Any may be null. |
| workspaceId | The Splice workspace the tool belongs to. |
| channel | website, email, whatsapp, instagram, or voice. |
| verified | True only when the channel proved the identity - see the channels table below. |
| identitySource | How it was proved: widget_otp, widget_unverified, email_from, whatsapp_phone, voice_caller_id, or voice_collected. |
| iss / iat / exp | Always "spliceai", issued-at, and issued-at + 300 seconds. |
Requires a secret
One exception on polling requests
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.Turn this on for anything account-specific. It does three things, all enforced server-side before your endpoint is ever called:
On the website widget, enabling it makes the chat widget ask visitors to verify their email by OTP before they can use that tool.
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").
| Column | Options | Notes |
|---|---|---|
| Type | Text, Number, Yes/No, Date | Models emit everything as text. Splice coerces numbers and booleans back before sending, so a strict endpoint won't reject "3". |
| In | Body, Path, Query, Header | Where 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. |
| Req | On / off | If 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
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
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.
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.
| Channel | Identity Splice has | Counts as verified? |
|---|---|---|
| Website widget | Visitor email confirmed by OTP | Yes, once the visitor verifies |
| The sender address on the inbound mail | Yes, when a sender is present | |
| The sender's phone number | Yes, when a number is present | |
| Voice | Carrier caller ID on inbound calls | Inbound calls with a number: yes. Outbound campaign calls: no. |
Tools can be scoped to one agent
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
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.
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
| What you see | What it means | Fix |
|---|---|---|
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 tool | It 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 processing | A job-mode tool didn't finish inside the live window. | Nothing to do - the follow-up message arrives automatically, up to 5 minutes later. |