Kostenlos testen
Handbuch

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:

  1. Löst apis.jtl.base_url + apis.jtl.headers auf (inkl. {{secret:...}}-Expansion).
  2. Ersetzt {id} im endpoint durch $args['id'].
  3. 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 statusCode im 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
Zuletzt aktualisiert: 2026-07-29