Skip to content

External Services

Daniel Ellison edited this page Mar 26, 2026 · 2 revisions

External Services

Kai includes a declarative service proxy that lets the inner Claude process call external APIs without ever seeing API keys. Services are defined in a YAML config file, and Kai handles authentication injection at call time.

How It Works

The service proxy follows the same local-proxy pattern as the scheduling API. Inner Claude already uses curl to hit local endpoints for scheduling. The service layer extends this to external API calls with authentication.

The flow:

Inner Claude  --curl-->  localhost:8080/api/services/{name}  --HTTP-->  External API
                         (webhook.py injects auth from .env)
  1. Inner Claude sends a curl request to the local proxy endpoint
  2. webhook.py validates the webhook secret, then hands off to services.py
  3. services.py looks up the service definition, resolves the API key from .env, and injects authentication into the outbound request
  4. The external API response is returned to Claude as JSON

API keys live in .env and are resolved at call time (not at startup), so you can rotate keys without restarting Kai.

Security Model

  • API keys never enter Claude's context. The inner Claude process only knows the local proxy URL and the webhook secret. Service definitions, API keys, and auth headers are handled entirely by Kai's own process.
  • Localhost only. The /api/services/{name} endpoint is not exposed through the Cloudflare Tunnel. Only processes on the local machine can reach it.
  • Webhook secret required. Every proxy request must include the X-Webhook-Secret header, same as the scheduling API.
  • SSRF protection. The path_suffix parameter is denied by default. Services must explicitly opt in with allow_path_suffix: true in their YAML definition. Even when allowed, path suffixes are validated to reject query strings (?), fragments (#), and path traversal (..). Without this flag, a service cannot be used as an open HTTP proxy to arbitrary URLs.
  • Response size cap. External API responses are truncated at 10MB to prevent out-of-memory conditions from unexpectedly large responses.
  • No code execution. Service definitions are inert data (URL, method, auth type, headers). There are no plugins, no middleware, no arbitrary code paths.

Setup

1. Create services.yaml

Copy the example file at the project root:

cp services.example.yaml services.yaml

2. Uncomment the services you want

Each service is a YAML block under the services: key. Uncomment and configure the ones you need.

3. Set API keys in .env

Add the corresponding environment variable for each service. For example, to enable Perplexity:

PERPLEXITY_API_KEY=your-perplexity-api-key

4. Restart Kai

Services are loaded at startup. You'll see a log line confirming which services were loaded:

Loaded 1 service(s): perplexity

If a service references an env var that isn't set, it's skipped with a warning (unless auth.optional is true).

Defining a Service

Each service entry in services.yaml describes a single HTTP endpoint:

services:
  perplexity:
    url: https://api.perplexity.ai/chat/completions
    method: POST
    auth:
      type: bearer
      env: PERPLEXITY_API_KEY
    headers:
      Content-Type: application/json
    description: "Web search and AI synthesis via Perplexity Sonar API"
    notes: >
      Send JSON body with "model" and "messages" array.

Required fields

Field Description
url Base URL for the API endpoint

Optional fields

Field Default Description
method GET HTTP method (GET or POST)
auth none Authentication config (see below)
headers {} Static headers included on every request
params {} Static query parameters included on every request
allow_path_suffix false Whether path_suffix is accepted in requests (see Security Model)
description "" What the service does (shown to inner Claude)
notes "" Usage hints for inner Claude (expected body format, models, etc.)

Authentication types

The auth block controls how Kai injects credentials into outbound requests:

Type Injection Example
bearer Authorization: Bearer $KEY Most modern APIs (Perplexity, OpenAI, Jina)
header {auth.name}: $KEY APIs with custom auth headers (e.g., X-Subscription-Token)
query ?{auth.name}=$KEY APIs that take keys as query params (e.g., OpenWeatherMap's appid)
none No authentication Public APIs, self-hosted services (ntfy, SearXNG)

The auth block fields:

Field Required Description
type Yes One of bearer, header, query, none
env For authenticated types Name of the .env variable holding the API key
name For header and query The header name or query parameter name
optional No (default false) If true, the service stays available even when the env var is unset

Setting optional: true is useful for services like Jina Reader that work without a key (rate-limited) but perform better with one.

Calling a Service

Inner Claude calls services via curl to the local proxy:

curl -s -X POST http://localhost:8080/api/services/{name} \
  -H 'Content-Type: application/json' \
  -H 'X-Webhook-Secret: YOUR_SECRET' \
  -d '{ ... }'

Request JSON fields

All fields in the request body are optional:

Field Type Description
body dict Forwarded as the JSON body to the external API
params dict Query parameters (merged with any static params from the service config)
path_suffix string Appended to the service's base URL

Response format

Success:

{"status": 200, "body": "..."}

Failure:

{"error": "..."}

Failures return HTTP 502 from the proxy. The error field describes what went wrong (timeout, connection error, unknown service, missing env var).

Example: Perplexity web search

curl -s -X POST http://localhost:8080/api/services/perplexity \
  -H 'Content-Type: application/json' \
  -H 'X-Webhook-Secret: YOUR_SECRET' \
  -d '{
    "body": {
      "model": "sonar",
      "messages": [{"role": "user", "content": "What happened today in tech news?"}]
    }
  }'

Example: Jina Reader (using path_suffix)

This works because Jina Reader's service definition includes allow_path_suffix: true. Services without this flag will reject requests that include path_suffix.

curl -s -X POST http://localhost:8080/api/services/jina_reader \
  -H 'Content-Type: application/json' \
  -H 'X-Webhook-Secret: YOUR_SECRET' \
  -d '{"path_suffix": "https://example.com/article"}'

Example: OpenWeatherMap (using params)

curl -s -X POST http://localhost:8080/api/services/openweathermap \
  -H 'Content-Type: application/json' \
  -H 'X-Webhook-Secret: YOUR_SECRET' \
  -d '{"params": {"q": "Toronto,CA", "units": "metric"}}'

Included Service Examples

The services.example.yaml file ships with five pre-configured services:

Service Auth type Purpose
Perplexity bearer Web search and AI synthesis. Recommended as the primary search tool.
OpenWeatherMap query Current weather data by city or coordinates.
ntfy none Push notifications to phone/desktop via ntfy.sh or self-hosted.
Jina Reader bearer (optional) Convert any URL to clean markdown text. Works without a key.
SearXNG none Self-hosted metasearch engine. Requires a running SearXNG instance.

Only Perplexity is uncommented by default. The others are provided as templates you can enable by uncommenting and setting the appropriate API keys.

Adding a New Service

To add your own service:

  1. Add a new entry to services.yaml following the existing patterns
  2. Set any required API keys in .env
  3. Restart Kai

That's it. No code changes needed. The service will appear in inner Claude's context automatically, and it can start calling it via the proxy endpoint.

Here's a minimal example for a hypothetical API:

services:
  my_api:
    url: https://api.example.com/v1/data
    method: GET
    auth:
      type: bearer
      env: MY_API_KEY
    description: "Fetch data from Example API"
    notes: >
      Pass query as params: {"q": "search term"}.
      Returns JSON with "results" array.

Context Injection

When services are configured, Kai automatically injects service information into inner Claude's session context alongside the scheduling API info. This includes:

  • The proxy endpoint URL and authentication header
  • A list of available services with their descriptions and usage notes
  • A usage example

This means inner Claude knows what services are available and how to call them without any manual prompting. The description and notes fields in your service definitions are what Claude sees, so write them clearly.

Troubleshooting

Service not appearing in Claude's context:

  • Check that services.yaml exists at the project root (not just the example file)
  • Check that the referenced env var is set in .env
  • Look at the startup logs for warnings about skipped services

"Unknown service" error:

  • The service name in the URL must match the key in services.yaml exactly

Timeouts:

  • The proxy has a 30-second timeout per request. If an external API is consistently slow, this can't be configured per-service currently.

Auth failures (401/403 from external API):

  • Verify the API key in .env is correct and active
  • Check that the auth type matches what the external API expects

Clone this wiki locally