Skip to navigation

Building a server for Studio

Answer Studio's tool requests, verify that they came from Studio, and shape what the agent hears back.

Your agents reach your own systems through your server. Studio sends it two kinds of request:

RequestWhenCovered here
Tool requestsduring a call, each time the model uses one of the agent’s HTTP tools, and when you press Test this toolyes
Result webhookonce per call, after it ends, if the agent has oneits signature, below. The payload is in Result webhook

This page is for any server that answers Studio’s tool requests: to look things up for the agent, to take actions, or to start and resolve situations. MCP servers work differently, and aren’t covered here; an MCP tool can still start and resolve situations, as Starting situations from your server explains.

A complete example in TypeScript, with tests, is at jambonz/agent-studio-situations-example.

What your server needs

  • A public https URL on a public domain name, with a valid certificate. Studio doesn’t send tool requests to plain http, to IP addresses in private ranges, or to names that resolve to them.
  • An answer within the tool’s timeout: 1 to 15 seconds, 8 by default. The caller waits in silence while it runs, so aim for well under a second.
  • No redirects. Studio doesn’t follow them; a 3xx answer is a failed request.

For a test against a server on your own machine, a tunnel such as ngrok gives you a public https URL.

The request

Each HTTP tool is one request: its method and URL, with the arguments the model fills in.

Arguments

A {name} in the tool’s URL is replaced with that argument, URL-encoded. The other arguments are sent according to the tool’s Send arguments as setting:

SettingHow the remaining arguments are sent
JSON bodyas a JSON object, Content-Type: application/json. With no arguments the body is {}
Formas application/x-www-form-urlencoded. A value that isn’t a string is sent as JSON text
Query stringadded to the URL’s query string

A GET request always sends them in the query string, and has no body.

Argument names reach your server exactly as the tool’s parameters name them.

Headers

HeaderWhat it is
X-Studio-Signaturethe request’s signature: see Verifying requests
X-Studio-Timestampthe signing time, in Unix seconds; the same as t in the signature
X-Studio-Toolthe tool’s name
X-Studio-Call-Sidthe jambonz call id. Not sent by Test this tool
Authorization, or the header you namedthe tool’s Authentication, if set: a bearer token, basic auth or an API key header
your ownthe tool’s Headers

Use X-Studio-Call-Sid to tie requests from the same call together, and to find the call in Calls.

Verifying requests

Studio signs every tool request. Check the signature before you act on a request: without it, anyone who finds your URL can call your tool.

X-Studio-Signature: t=<unix seconds>,v1=<hex>
v1 = hex(HMAC-SHA256(signing_secret, t + "." + METHOD + "." + path_with_query + "." + raw_body))
PartWhat it is
tthe signing time, in Unix seconds
METHODthe HTTP method in capitals, such as POST
path_with_querythe request’s path and query string exactly as received, such as /orders/42?full=1. No scheme or host
raw_bodythe body exactly as received, before any parsing. Empty for a request without a body

Reject the request when v1 doesn’t match, or when t is more than 300 seconds from your server’s clock. Compare signatures in constant time.

The signing secret

Each HTTP tool has its own signing secret. Studio creates it when you first save the tool and shows it once. Rotate secret… on the tool replaces it: requests are signed with the new secret from then on, so give your server the new one straight away.

Keep each tool’s secret in your server’s configuration or secret store, never in code, and look it up by tool: X-Studio-Tool, or the path you served the tool on.

Node.js

import { createHmac, timingSafeEqual } from 'node:crypto';
const MAX_SKEW_S = 300;
// method: 'POST'; pathWithQuery: req.url as received; rawBody: the body as a string, before parsing
export function verifyToolRequest(secret, header, method, pathWithQuery, rawBody) {
if (!header) return false;
const parts = Object.fromEntries(
header.split(',').map((p) => {
const i = p.indexOf('=');
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 ?? '')) return false;
if (Math.abs(Date.now() / 1000 - t) > MAX_SKEW_S) return false;
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${method}.${pathWithQuery}.${rawBody}`)
.digest();
return timingSafeEqual(expected, Buffer.from(parts.v1, 'hex'));
}

With Express, read the raw body with express.raw({ type: '*/*' }) (or keep it with the verify option of express.json()), and use req.originalUrl for the path and query.

Python

import hashlib, hmac, time
MAX_SKEW_S = 300
def verify_tool_request(secret: str, header: str | None, method: str, path_with_query: str, raw_body: bytes) -> bool:
if not header:
return False
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > MAX_SKEW_S:
return False
signed = f"{t}.{method}.{path_with_query}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)

Read the body with your framework’s raw-body call, such as request.get_data() in Flask or await request.body() in FastAPI. Take the path and query from the request line as received: some frameworks decode the path, which breaks the signature for any path with an encoded character. Under Gunicorn, the WSGI environ’s RAW_URI has it unchanged.

Common mistakes

  • Verifying the parsed body. Re-serialised JSON rarely matches byte for byte. Verify the raw body, then parse it.
  • Using the full URL. Sign only the path and query string, as received, without the scheme and host.
  • Rebuilding the query string. Use it as it arrived; reordering or re-encoding it changes the signature.
  • Using the result webhook’s formula. It signs a different string: see below.

The result webhook’s signature

The result webhook is signed in the same header with the same t and v1 parts, but v1 covers only the timestamp and the body, and the agent’s webhook signing secret (starting whsec_) is the key:

v1 = hex(HMAC-SHA256(webhook_signing_secret, t + "." + raw_body))

The response

Answer with a 2xx status and a JSON object. What happens next depends on the status:

Your serverWhat the model gets
answers 2xx with JSONthe object, shaped by the tool’s Response settings
answers 2xx with anything elsethe text, as { "result": "<text>" }
answers 4xx{ "error": "HTTP <status>", "body": "<the first 500 characters>" }
answers 5xx, or can’t be reachedthe same error, or request failed; a GET is retried first if the tool has Retries
doesn’t answer within the timeouttimeout after <n>s
answers with a redirectredirects not followed
sends more than 256 KBresponse too large

Only GET requests are retried, up to twice, and only within the tool’s timeout, so a POST never reaches your server twice. Make a GET safe to repeat.

When a request fails, the tool’s If the request fails setting decides what the caller hears: the model is told it failed and recovers, or the agent says a fixed line.

What the model sees

The tool’s Response settings shape the result before the model reads it:

  • The full body, or only the value at a Field path such as order.status or items[0].name;
  • cut to Max characters, 2,000 by default. Longer results slow the agent and use tokens.

Return what the model needs to answer the caller, in plain field names, and nothing else. A field like "next_step": "Ask whether they want it delivered or collected." works better than a code the model has to interpret. Never return data the caller shouldn’t hear: the model may read out anything in the result.

Changing situations

A result can start or resolve a situation with an actions object:

{ "verified": true, "actions": { "situation": "negotiation" } }

Studio applies it and removes it before the model sees the result, even with a Field path or when the result is cut. See Starting situations from your server.

Testing

  • Test this tool, on the tool, sends a real, signed request from Studio’s servers with sample values, and shows the request, your response and what the model would receive. It tests the saved tool, so save first.
  • A test call shows each tool call on its turn, with its arguments, result and round-trip time.
  • Log each request’s tool, X-Studio-Call-Sid, status and timing. Logging whole bodies helps while testing, but they hold what callers said, so turn it off for real calls.

Checklist

  • public https URL, no redirects
  • answers within the tool’s timeout, ideally under a second
  • verifies X-Studio-Signature over the raw body, with each tool’s own secret
  • rejects signatures more than 300 seconds old
  • returns a JSON object with only what the model needs
  • GET tools are safe to repeat
  • logs the call id with each request, and no caller data in production

Next