API-Features — was du deklarativ machen kannst, ohne Handler
Preprocess, mapping, collection, body_transform, _query, Placeholder — die vollständige Feature-Liste für API-Tools.
API-Features — deklarativ statt Handler
Wenn dein Tool mit einer externen HTTP-API redet: schreibe keinen Handler, wenn du es vermeiden kannst. Der ToolRunner hat einen kompletten deklarativen Layer der HTTP-Requests, Response-Umformung und Auth für dich macht.
Ein Handler ist nur dann sinnvoll wenn:
- die Logik echte Berechnung ist (nicht nur Daten-Umformung),
- mehrere API-Calls verkettet werden müssen,
- oder wenn du auf
$ctx-Zeug angewiesen bist das die deklarativen Features nicht abdecken.
Sonst: api + endpoint + optional mapping/preprocess/postprocess reicht.
Minimal-Beispiel — API-only, kein Handler
'getInvoice' => [
'description' => 'Rechnung per ID abrufen.',
'parameters' => [
'type' => 'object',
'properties' => ['id' => ['type' => 'string']],
'required' => ['id'],
],
'api' => 'jtl', // → apis.jtl aus tenantConfig
'method' => 'GET', // Default: GET
'endpoint' => '/invoices/{id}', // {id} wird aus $args['id'] ersetzt
],
Der Runner:
- Löst
apis.jtl.base_url+apis.jtl.headersauf (inkl.{{secret:...}}-Expansion). - Ersetzt
{id}im endpoint durch$args['id']. - Feuert GET, gibt die JSON-Response ans LLM.
Kein Handler, kein Cake\Http\Client, kein ->getJson() — alles vom Runner erledigt.
URL-Placeholder — jeder skalare Arg ersetzt {arg_name}
Alle scalaren Argumente ersetzen automatisch {arg_name} im endpoint. Nested/Object-Args werden übersprungen.
'endpoint' => '/customers/{customer_id}/orders/{order_id}',
Wenn $args = ['customer_id' => 42, 'order_id' => 'ORD-99']: ergibt /customers/42/orders/ORD-99.
Werte, die unverändert vom Aufrufer kommen, kodiert der Runner selbst mit rawurlencode — ein /, ?, # oder %2F im Arg kann so nicht auf einen anderen Endpoint ausweichen.
Konvention: preprocess, das einen Aufrufer-Arg zurückgibt, kodiert selbst
Gibt preprocess einen Key zurück, gilt der Wert als Preprocess-Wert und wird roh in den Endpoint eingesetzt — auch wenn der Key genauso heißt wie ein Aufrufer-Arg. Der Runner weiß dann nicht mehr, dass der Inhalt ursprünglich vom Aufrufer stammt.
Deshalb: Wer einen Aufrufer-Arg unter demselben Key zurückgibt (normalisiert, getrimmt, umformatiert), muss ihn selbst mit rawurlencode kodieren.
'endpoint' => '/customers/{customer_id}',
'preprocess' => function (array $args, array $ctx): array {
// RICHTIG: Aufrufer-Wert unter gleichem Key → selbst kodieren.
return ['customer_id' => rawurlencode(trim((string)($args['customer_id'] ?? '')))];
// FALSCH: ['customer_id' => trim($args['customer_id'])]
// → "../admin" oder "1?x=y" landet roh im Pfad.
},
Werte, die preprocess selbst erzeugt (Token, IDs aus Config), sind unkritisch. _query wird immer kodiert und kommt nur aus preprocess — ein vom Aufrufer gesetztes _query verwirft der Runner.
Query-String — via _query aus preprocess
Wenn du dynamische Query-Parameter brauchst (Filter, Pagination), setze sie im preprocess-Callback als _query-Array. Der Runner url-encoded das automatisch an den endpoint an.
'listOrders' => [
'description' => 'Bestellungen auflisten (Filter: status, datum-von/bis).',
'parameters' => [
'type' => 'object',
'properties' => [
'status' => ['type' => 'string', 'enum' => ['open', 'shipped']],
'since' => ['type' => 'string', 'description' => 'YYYY-MM-DD'],
],
],
'api' => 'shopify',
'method' => 'GET',
'endpoint' => '/orders.json',
'preprocess' => function (array $args, array $ctx): array {
$q = [];
if (!empty($args['status'])) $q['status'] = $args['status'];
if (!empty($args['since'])) $q['created_at_min'] = $args['since'];
return ['_query' => $q];
},
],
mapping — Response-Felder umbenennen und flach ziehen
Wenn die API ein verschachteltes JSON zurückgibt und du dem LLM nur die relevanten Felder mit sauberen Namen zeigen willst:
'getInvoice' => [
'api' => 'jtl',
'endpoint' => '/invoices/{id}',
'mapping' => [
'id' => 'invoice_id', // → response.invoice_id
'total' => 'amounts.gross', // → response.amounts.gross (dot-notation)
'customer' => 'customer.name',
'items' => 'lines',
],
],
Response { "invoice_id": "INV-1", "amounts": { "gross": 119, "net": 100 }, "customer": { "name": "ACME" } } wird zu { "id": "INV-1", "total": 119, "customer": "ACME" }.
collection — Array-Response entnesten
Wenn die API ein Wrapping-Objekt zurückgibt (z.B. { "data": [...], "meta": {...} }) und du nur das Array willst:
'listInvoices' => [
'api' => 'jtl',
'endpoint' => '/invoices',
'collection' => 'data', // greift auf response.data zu
'mapping' => [
'id' => 'invoice_id',
'total' => 'amounts.gross',
],
],
collection wird BEFORE mapping angewendet — jede Zeile im Array wird dann durch das Mapping gejagt.
Dot-Notation funktioniert auch: 'collection' => 'response.items' greift auf response.items zu.
body_transform — Body vor dem POST umformen
Bei POST/PUT/PATCH werden $args per default 1:1 als Body geschickt. Wenn die API einen anderen Body-Shape erwartet:
'createTicket' => [
'api' => 'zendesk',
'method' => 'POST',
'endpoint' => '/tickets.json',
'json' => true,
'body_transform' => function (array $args): array {
return [
'ticket' => [
'subject' => $args['title'],
'comment' => ['body' => $args['message']],
'priority' => $args['priority'] ?? 'normal',
],
];
},
],
$args = ['title' => 'X', 'message' => 'Y'] → Body wird zu {"ticket": {"subject":"X", "comment":{"body":"Y"}, "priority":"normal"}}.
json — JSON-Encoding aktivieren
Standardmäßig serialisiert der Runner den Body als form-encoded. Für JSON-APIs:
'createTicket' => [
'api' => 'zendesk',
'method' => 'POST',
'endpoint' => '/tickets.json',
'json' => true, // → Content-Type: application/json, Body als JSON
],
postprocess — Response nachbearbeiten (statt Handler)
Für Berechnungen die auf dem API-Result laufen sollen — bevor der Handler-Fallback greift.
'getInvoice' => [
'api' => 'jtl',
'endpoint' => '/invoices/{id}',
'postprocess' => function (array $result, array $args, array $ctx): array {
$result['totalWithTax'] = round($result['net'] * 1.19, 2);
return $result;
},
],
Ziel-Adressen (SSRF-Schutz)
Für Workspaces ohne Repo (Signup, tenants_untracked, tm-CLI) prüft der
ToolRunner jede URL des api-Zweigs vor dem Request (EgressGuard):
- nur
https://auf Port 443, keine Zugangsdaten in der URL - der Host muss auf öffentliche Adressen auflösen (kein localhost, LAN, Link-Local/Metadaten-API, TaskMonkey selbst)
- die geprüfte IP wird gepinnt (kein DNS-Rebinding), Redirects werden nicht
verfolgt (ein 30x kommt als
statusCodeim Fehler zurück), kein Proxy
Blockierte Ziele liefern {"error": "API-Ziel nicht erlaubt", "hint": "…"}.
Git-getrackte Workspaces (config/tenants/<code>/) sind davon nicht betroffen —
außer die API setzt 'egress_guard' => true in apis.<name>: dann gilt der
Guard auch dort. Pflicht für APIs, deren Ziel aus Daten kommt (z.B.
_shared/shop_homepage mit base_url => 'https://{site_host}' für
detectShopSystem, config/tenants/_shared/tools/shop/detectShopSystem.php). Ein 30x kommt dort als Erfolg mit status zurück; den
location-Header liefert response_headers (siehe Katalog), das Tool folgt
dem Redirect selbst über denselben Weg.
Entscheidungs-Baum: brauche ich einen Handler?
| Situation | Handler nötig? | Alternative |
|---|---|---|
| Ein HTTP-Call, Response direkt zurückgeben | ❌ | api + endpoint |
| Response-Felder umbenennen/entnesten | ❌ | mapping + evtl. collection |
| Dynamischer Query-String (Filter, Pagination) | ❌ | preprocess mit _query |
| Body für POST muss umgeformt werden | ❌ | body_transform + json: true |
| Netto → Brutto rechnen auf Response | ❌ | postprocess |
| Mehrere API-Calls verketten | ✅ | Handler oder Nested-Tool-Call |
| Datei generieren und speichern | ✅ | Handler mit writeFile |
| Reine Berechnung ohne API | ✅ | Handler-only |
| DB-Query mit ConnectionManager | ✅ | Handler-only |
Vor jedem Handler-Prompt: frag dich ob 3-4 deklarative Zeilen dasselbe können. Wenn ja: keinen Handler.
Kompletter Feature-Katalog
Alle Keys die im Tool-Config-Array erkannt werden:
| Key | Typ | Zweck |
|---|---|---|
api |
string | Referenz auf apis.<name> im Tenant-Config |
method |
string | GET/POST/PUT/PATCH/DELETE (Default: GET) |
endpoint |
string | Pfad, mit {arg}-Placeholder |
json |
bool | Body als JSON encoden (Content-Type: application/json) |
content_type |
string | Override für Content-Type (z.B. image/png für Media-Upload) |
content_disposition_filename |
string | Für Multipart-Uploads, {arg}-Placeholder erlaubt |
parse_json |
bool | false → Result immer {body, status} (roher String, z.B. HTML) |
response_headers |
list | Nur mit parse_json: false: diese Antwort-Header (lowercase) als headers ins Result, z.B. ['location', 'set-cookie'] |
http_timeout |
int | Timeout in Sekunden für diesen Request (1–60). Vorrang vor apis.<name>.timeout (bis 60 s im Web-Request, bis 300 s in CLI); ohne beides gilt das Client-Timeout |
max_response_bytes |
int | Antwort höchstens so groß (Bytes, min. 1024): curl bricht beim Laden ab (CURLOPT_MAXFILESIZE + Fortschritts-Callback), statt alles in den Speicher zu holen. Für fremde Websites, z. B. shopFetchPage (3 MB). |
preprocess |
callable | function(array $args, array $ctx): array — merged Result in $args. Special-Key _query wird zum URL-Query-String. Aufrufer-Arg unter gleichem Key zurückgeben → selbst rawurlencode |
postprocess |
callable | function(array $result, array $args, array $ctx): array — läuft nach API-Response, vor Mapping |
body_transform |
callable | function(array $args): array — formt POST-Body um |
mapping |
array | ['newKey' => 'response.dot.path'] — flacht/renamed Response-Felder |
collection |
string | Dot-Path zum Array in der Response (z.B. 'data' oder 'response.items') |
handler |
callable | Fallback für alles was nicht deklarativ geht. function(array $results, array $args, array $ctx): array |
description |
string | Was das Tool tut (fürs LLM) |
parameters |
array | JSON-Schema für die Args |
statusMessages |
array | Kurze Meldungen die während der Ausführung im Chat gezeigt werden |
args_fixture |
array | Default-Args für tm test-tool |
internal |
bool | Ist das Tool "intern" (nicht dem User erwähnenswert) |
async |
bool | Läuft in Background-Queue statt inline |
extends |
string | Basis-Definition aus _shared erben |
only_via |
list | Tool nur als Sub-Tool aus diesen Tools aufrufbar (z.B. ein Geld ausgebender Mutate nur aus der Hülle, die vorher per confirmChatAction freigeben lässt). Direkt vom LLM, Public API, Scheduled Task oder runTool → error_code: only_via |