Skip to content

9. Call your code

Build a support agent for an online shop that looks orders up on your server. Your server answers only calls that carry its key, and that key lives in Maitento as a secret: it’s never in a project file, and the model never sees it. This is how you connect agents to your own systems: your API, your database, your business rules.

You’ll learn

  • how to point a tool at your own API
  • how to keep your API key in a secret that Maitento sends in a header
  • what the agent does when your server turns it away
  • how each run can choose which key to send, so one agent can serve several shops

New concepts: secrets · tool authentication

Builds on: 8. Your first tool

Time: ~25 min · Cost: under $0.01 on DeepSeek · Needs: a public address for your laptop. A free Cloudflare quick tunnel works, with no account: cloudflared tunnel --url http://localhost:3000

This small API is what the agent will call. It keeps three orders in memory, for two shops, and answers only calls whose X-Api-Key header holds a shop’s key. Each shop sees only its own orders. You need only the first shop until the last part of this tutorial, and the refund endpoint is for tutorial 10.

// A tiny orders API. Maitento calls it for the agent's get-order tool, and for refund-order in tutorial 10.
import { timingSafeEqual } from "node:crypto";
import express from "express";
const apiKey = process.env.ORDERS_API_KEY;
if (!apiKey) throw new Error("Set ORDERS_API_KEY to the value you stored in the orders-api-key secret.");
// Each shop has its own key, and sees only its own orders. Shop 2 is for the end of tutorial 9: setting
// SECOND_SHOP_API_KEY opens it.
const shopKeys = new Map<string, string>([["shop-1", apiKey]]);
if (process.env.SECOND_SHOP_API_KEY) shopKeys.set("shop-2", process.env.SECOND_SHOP_API_KEY);
type Order = {
id: string; shop: string; customer: string; items: string[];
total: number; currency: string; status: string; refunded: number;
};
const orders = new Map<string, Order>([
["A-1001", { id: "A-1001", shop: "shop-1", customer: "Sam Lee", items: ["Blue mug", "Tea sampler"], total: 34.5, currency: "GBP", status: "delivered", refunded: 0 }],
["A-1002", { id: "A-1002", shop: "shop-1", customer: "Ana Silva", items: ["Desk lamp"], total: 59.9, currency: "GBP", status: "in transit", refunded: 0 }],
["B-2001", { id: "B-2001", shop: "shop-2", customer: "Kai Moreno", items: ["Oak bookshelf"], total: 189, currency: "EUR", status: "in transit", refunded: 0 }],
]);
// The shop whose key a call sends in X-Api-Key, compared in constant time so it can't be guessed from timing.
function shopFor(sentKey: string | undefined): string | undefined {
const sent = Buffer.from(sentKey ?? "");
for (const [shop, key] of shopKeys) {
const expected = Buffer.from(key);
if (sent.length === expected.length && timingSafeEqual(sent, expected)) return shop;
}
return undefined;
}
// The order, if it exists and belongs to the shop that asks.
function findOrder(orderId: string, shop: string | undefined): Order | undefined {
const order = orders.get(orderId);
return order && order.shop === shop ? order : undefined;
}
const app = express();
app.use(express.json());
// Maitento sends the secret's value in the X-Api-Key header. Anything else is turned away.
app.use((req, res, next) => {
if (!shopFor(req.get("X-Api-Key"))) {
console.log("401: a call without a known X-Api-Key");
res.status(401).json({ error: "Unauthorised" });
return;
}
next();
});
app.get("/orders/:orderId", (req, res) => {
const shop = shopFor(req.get("X-Api-Key"));
console.log(`${shop}: GET /orders/${req.params.orderId}`);
const order = findOrder(req.params.orderId, shop);
if (!order) {
res.status(404).json({ error: `There is no order ${req.params.orderId}` });
return;
}
res.json(order);
});
app.post("/orders/:orderId/refund", (req, res) => {
const shop = shopFor(req.get("X-Api-Key"));
console.log(`${shop}: POST /orders/${req.params.orderId}/refund`);
const order = findOrder(req.params.orderId, shop);
if (!order) {
res.status(404).json({ error: `There is no order ${req.params.orderId}` });
return;
}
const { amount, reason } = req.body as { amount: number; reason: string };
const refundable = order.total - order.refunded;
if (amount <= 0 || amount > refundable) {
res.status(400).json({ error: `You can refund between 0.01 and ${refundable.toFixed(2)} ${order.currency}` });
return;
}
order.refunded += amount;
console.log(`Refunded ${amount} ${order.currency} on ${order.id}: ${reason}`);
res.json({ orderId: order.id, refunded: amount, totalRefunded: order.refunded });
});
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`Orders API listening on http://localhost:${port}`));

Pick a long random key, and start the server on port 3000:

export ORDERS_API_KEY="pick-a-long-random-value"
cd examples/09-call-your-code/typescript && npm install && node server.ts

In another terminal, with the same ORDERS_API_KEY, ask it for an order:

curl -H "X-Api-Key: $ORDERS_API_KEY" http://localhost:3000/orders/A-1002

Done when: curl prints order A-1002 as JSON, and the server prints shop-1: GET /orders/A-1002. Without the header, curl gets {"error":"Unauthorised"}.

Maitento calls your server from the internet, so localhost isn’t enough: it only calls addresses on the public internet, never localhost, a private network or an internal name. For trying things out, a quick tunnel gives your laptop a public https address:

cloudflared tunnel --url http://localhost:3000
# … Your quick Tunnel has been created! Visit it at: https://quiet-forest-1234.trycloudflare.com

Done when: the curl from step 1 works with the tunnel’s address in place of http://localhost:3000.

Put that address in the connector file, in place of https://YOUR-SERVER.example.com. auth = "Header" says that calls to this endpoint need a credential in a header:

maitento/connectors/open-api/orders-get.mtr
// Replace the host with your server's public URL (see the tutorial for a free tunnel).
method = "GET"
url = "https://YOUR-SERVER.example.com/orders/{orderId}"
description = "Looks up one order by its id"
auth = "Header"
parameters.path = [
{
name = "orderId"
description = "The order id, for example A-1001"
}
]

The tool names the header and the secret whose value goes in it. The secret’s value is stored in Maitento, never in your project files, and the model never sees it, or even that there is one:

maitento/external-operations/open-api/get-order.mtr
connector = @ref("orders-get")
// Maitento sends the secret's value in this header. Your server checks it.
auth = {
header = "X-Api-Key"
secret = @ref("orders-api-key")
}
guidance = "Looks up an order: its items, total, status and how much has already been refunded."

The agent has the tool, as in tutorial 8:

maitento/agents/support-agent.mtr
model = @ref("deepseek.deepseek-v4-flash")
prompt = "You are a customer support agent for an online shop. Look orders up before you answer, and be precise about amounts."
summary = "Answers questions about orders"
functions = [
{
ref = @ref("get-order")
}
]

The support interaction passes the customer’s message to the agent.

The script stores your key as the orders-api-key secret, then deploys. The order matters: a deploy checks that every secret its tools name already exists.

import { Maitento } from "@maitento/sdk";
const apiKey = process.env.ORDERS_API_KEY;
if (!apiKey) throw new Error("Set ORDERS_API_KEY first: the same value your orders API checks.");
const mt = new Maitento();
// Stores a value as a Maitento secret, or replaces the value of the one with that name.
async function storeSecret(name: string, value: string) {
const existing = await mt.secrets.findByName(name);
if (existing) await mt.secrets.update(existing.id, { name, value });
else await mt.secrets.create({ name, value });
}
// The key your orders API expects. The get-order tool sends it in the X-Api-Key header.
await storeSecret("orders-api-key", apiKey);
// The second shop's key, for the end of the tutorial.
if (process.env.SECOND_SHOP_API_KEY) await storeSecret("second-shop-api-key", process.env.SECOND_SHOP_API_KEY);
// The secret has to exist before the tools that use it are deployed.
await mt.deploy("../maitento");
console.log("Secrets stored and the support agent deployed.");

Run it from the language’s folder with ORDERS_API_KEY set: node setup.ts, python setup.py or dotnet run setup.cs.

Done when: it prints Secrets stored and the support agent deployed. The portal’s Secrets page lists orders-api-key, without its value.

import { Maitento, transcript } from "@maitento/sdk";
const mt = new Maitento();
const message = "Hi, I ordered a desk lamp last week (order A-1002). Has it shipped yet?";
console.log(`Customer: ${message}\n`);
// The agent looks the order up on your server before it answers.
const run = await mt.interactions.run("support", { inputs: { message } });
// What the agent did: each tool call, and what your server answered.
for (const entry of transcript(run)) {
if (entry.kind === "toolCall") console.log(`Tool call: ${entry.functionName} ${entry.argumentsJson}`);
if (entry.kind === "toolResult") {
const { result } = await mt.interactionRuns.toolResult(run.id, entry.sequence);
console.log(`Tool result: ${result}`);
}
}
console.log(`\nSupport: ${run.output}`);
Customer: Hi, I ordered a desk lamp last week (order A-1002). Has it shipped yet?
Tool call: get-order {"additionalInputs":{"path.orderId":"A-1002"}}
Tool result: {"id":"A-1002","shop":"shop-1","customer":"Ana Silva","items":["Desk lamp"],"total":59.9,"currency":"GBP","status":"in transit","refunded":0}
Support: Order A-1002 (one desk lamp, total £59.90) is currently **in transit**, so it has shipped. No refunds have been issued on this order.

Done when: you see the get-order call for A-1002, your order as its result, and your server prints shop-1: GET /orders/A-1002.

What happened:

  1. The model saw get-order, described by the tool’s guidance, with the connector’s orderId path parameter as its only argument.
  2. It called the tool for A-1002. Maitento sent GET /orders/A-1002 to your server, with the secret’s value in X-Api-Key.
  3. Your server checked the key, answered with the order, and the agent wrote its reply from it.

Keys get rotated. Stop the server with Ctrl+C, and start it again with a new key that Maitento doesn’t have:

ORDERS_API_KEY="a-new-key-maitento-does-not-have" node server.ts

Ask again. Your server prints 401: a call without a known X-Api-Key, and the script prints:

Tool call: get-order {"additionalInputs":{"path.orderId":"A-1002"}}
Tool result: {"error": "Remote API returned status code: Unauthorized with response: {\"error\":\"Unauthorised\"}"}
Support: I attempted to look up order A-1002, but the order system returned an "Unauthorised" error, so I'm unable to retrieve its status at this time. …
  • Your server’s 401 reached the agent as the tool’s result, like Open-Meteo’s 400 in tutorial 8. The run didn’t fail: the agent explained that it couldn’t check the order.
  • The agent can’t fix it. It never sees the key. You fix it in Maitento: run setup again with the new ORDERS_API_KEY. Tools name the secret, not its value, so the next call sends the new value. There’s nothing to redeploy.

Run setup with the new key, or restart the server with the old one, before you go on.

So far the tool always sends orders-api-key. Now suppose your agent serves several shops, each with its own key for the orders API. Instead of one agent per shop, the run can choose the secret: declare it on the interaction with inputs.functions.auth.

Open the second shop. In both terminals, set a second key next to the first:

export SECOND_SHOP_API_KEY="another-long-random-value"

Then restart the server, and run setup again: it stores the second key as the second-shop-api-key secret, and deploys the interaction below.

The project’s second interaction is the same support desk, with one addition:

maitento/interactions/one-shot/support-any-shop.mtr
// The support desk for any shop: each run says which shop's key the get-order tool sends.
agent = {
name = "Support"
agent = @ref("support-agent")
}
task = "A customer writes: %%message%%"
inputs.prompts = [
{
name = "message"
description = "What the customer wrote"
required = true
}
]
// The run passes the id of a secret, and Maitento sends that secret's value in X-Api-Key instead of the one the tool
// names. The agent never sees it.
inputs.functions.auth = [
{
name = "shopKey"
description = "The id of the secret that holds the shop's API key"
function = @ref("get-order")
agent = @ref("support-agent")
header = "X-Api-Key"
required = true
}
]
  • name is the run input, shopKey. Its value is the secret’s id: not its name, and never its value.
  • function and agent say which tool, and whose, as for the bindings in tutorial 8.
  • header is where the value goes. It replaces the tool’s own auth for this run.
  • required = true means every run must say which shop. With required = false, a run that passes nothing uses the tool’s own secret, or the input’s default, secret = @ref("…").

The script looks the secret up by name and passes its id:

import { Maitento, transcript } from "@maitento/sdk";
// Which shop the customer is writing to: the name of the secret holding its key.
const secretName = process.argv[2] ?? "second-shop-api-key";
const mt = new Maitento();
// A run takes the secret's id: never its value, which stays in Maitento.
const secret = await mt.secrets.getByName(secretName);
const message = "Hi, has my oak bookshelf (order B-2001) shipped yet?";
console.log(`Customer (${secretName}): ${message}\n`);
const run = await mt.interactions.run("support-any-shop", { inputs: { message, shopKey: secret.id } });
for (const entry of transcript(run)) {
if (entry.kind === "toolResult") {
const { result } = await mt.interactionRuns.toolResult(run.id, entry.sequence);
console.log(`Tool result: ${result}`);
}
}
console.log(`\nSupport: ${run.output}`);

Run it as the second shop (no argument), then as the first: node ask-shop.ts orders-api-key, python ask_shop.py orders-api-key or dotnet run ask-shop.cs -- orders-api-key.

Customer (second-shop-api-key): Hi, has my oak bookshelf (order B-2001) shipped yet?
Tool result: {"id":"B-2001","shop":"shop-2","customer":"Kai Moreno","items":["Oak bookshelf"],"total":189,"currency":"EUR","status":"in transit","refunded":0}
Support: Yes — order B-2001 (Oak bookshelf, €189.00) is currently **in transit**, so it has shipped. Nothing has been refunded on this order.
Customer (orders-api-key): Hi, has my oak bookshelf (order B-2001) shipped yet?
Tool result: {"error": "Remote API returned status code: NotFound with response: {\"error\":\"There is no order B-2001\"}"}
Support: I checked and there is no order with the reference B-2001 in our system. It's possible the reference is slightly different, or the order was placed under another account. …

Done when: the same question finds order B-2001 with the second shop’s key, and not with the first’s. Your server prints shop-2: GET /orders/B-2001, then shop-1: GET /orders/B-2001.

The agent can’t switch shops: it never sees which key it is using, so a customer of one shop can’t talk it into reading another’s orders. Two things to know:

  • A run that passes a name fails to start: requires valid GUID for secret ID shopKey - but value is: second-shop-api-key.
  • An id that isn’t one of your secrets starts the run, but the tool call fails, and the agent is told There was a problem calling the function: Could not find secret … for connector orders-get.
  • The deploy fails with Unable to resolve resource reference for secret 'orders-api-key'. The secret doesn’t exist yet. Run setup, which stores it before it deploys, or mt secret-create orders-api-key --value ….
  • The deploy fails with The connector's URL must be a public address: localhost is a loopback, private, link-local or internal address, which Maitento never calls [api:connectors:address-blocked]. The connector points at localhost (or 127.0.0.1, or a name on your own network), and Maitento only calls addresses on the public internet. Use the tunnel’s address.
  • Tool result: {"error": "Maitento won't call …: the address isn't on the public internet. …", "code": "api:connectors:address-blocked"}. The connector’s name resolves to a private address, such as a name that only works on your own network. Use the tunnel’s address.
  • Error connecting to remote API: Name or service not known (….trycloudflare.com:443). The tunnel has stopped. A quick tunnel gets a new address every time it starts: put the new one in the connector and run setup again.
  • Remote API returned status code: Unauthorized. The key in the secret and the key your server checks differ. See step 6.
  • address already in use when the server starts. Something else has port 3000. Start the server with PORT=3001 (Python: --port 3001) and point the tunnel at that port.
  • Your server is slow. Maitento waits up to 100 seconds for an answer, then gives the agent an error.

On GitHub: examples/09-call-your-code · TypeScript · Python · C#

All the files in this project (5)
model = @ref("deepseek.deepseek-v4-flash")
prompt = "You are a customer support agent for an online shop. Look orders up before you answer, and be precise about amounts."
summary = "Answers questions about orders"
functions = [
{
ref = @ref("get-order")
}
]

You gave an agent a tool on your own API. Your key stays in Maitento, and you watched what happens when the key is wrong, and how a run can choose between keys.

  • Secret: a named value stored in your tenant. Project files name it; only Maitento reads its value. It must exist before you deploy anything that names it.
  • auth on a tool: the header, and the secret whose value goes in it. The connector’s auth = "Header" says a credential is needed.
  • A key chosen by the run: inputs.functions.auth on the interaction. The run passes a secret’s id, and the agent never sees which.
  • Errors from your server, like a 401 or a 404, reach the agent as the tool’s result.

Challenge: Ana wants to know when her lamp will arrive, and an order only says in transit. Add a tracking endpoint to your API, GET /orders/{orderId}/tracking, that answers with a carrier, a tracking number and an estimated delivery date. Then add a connector and a tool for it, give the tool to the support agent, and ask “When will my desk lamp (order A-1002) arrive?”.

Done when: the agent calls your new tool, and its answer gives the carrier and the date.

The solution is in examples/09-call-your-code/solution: the server with the new endpoint in all three languages, the orders-tracking connector, the track-order tool, and a track script.

Customer: Hi, when will my desk lamp (order A-1002) arrive?
Tool call: get-order {"additionalInputs":{"path.orderId":"A-1002"}}
Tool call: track-order {"additionalInputs":{"path.orderId":"A-1002"}}
Support: Thank you for your enquiry regarding order A-1002.
Your desk lamp (total £59.90) is currently in transit and has been dispatched with DPD. The tracking number is 15502938476154, and the estimated delivery date is 28 October 2026.

Next: 10. Approvals lets the agent refund orders, with a person approving each refund first.