-
Notifications
You must be signed in to change notification settings - Fork 7
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.
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)
- Inner Claude sends a
curlrequest to the local proxy endpoint -
webhook.pyvalidates the webhook secret, then hands off toservices.py -
services.pylooks up the service definition, resolves the API key from.env, and injects authentication into the outbound request - 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.
- 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-Secretheader, same as the scheduling API. -
SSRF protection. The
path_suffixparameter is denied by default. Services must explicitly opt in withallow_path_suffix: truein 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.
Copy the example file at the project root:
cp services.example.yaml services.yamlEach service is a YAML block under the services: key. Uncomment and configure the ones you need.
Add the corresponding environment variable for each service. For example, to enable Perplexity:
PERPLEXITY_API_KEY=your-perplexity-api-key
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).
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.| Field | Description |
|---|---|
url |
Base URL for the API endpoint |
| 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.) |
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.
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 '{ ... }'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 |
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).
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?"}]
}
}'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"}'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"}}'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.
To add your own service:
- Add a new entry to
services.yamlfollowing the existing patterns - Set any required API keys in
.env - 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.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.
Service not appearing in Claude's context:
- Check that
services.yamlexists 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.yamlexactly
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
.envis correct and active - Check that the auth type matches what the external API expects