> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qlaud.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom HTTP endpoint (http-call)

> Wrap any REST endpoint as a tool with a JSON config — no webhook to host, no code to deploy. Templated URL/headers/body with auto-injected end-user session context.

The `qlaud-builtin/http-call` scaffold is qlaud's universal Tier-3 tool:
**any HTTP endpoint becomes a callable tool with one config blob, zero
code, and zero infrastructure on your side.** The gateway makes the
outbound request directly from the edge worker and returns the response
to the model.

When to reach for it:

* You have an internal API the AI should be able to call.
* You want to expose a third-party SaaS endpoint that doesn't have an
  MCP server and isn't in our named scaffold catalog.
* You need precise control over the request shape (specific headers,
  custom auth, GraphQL queries, form-encoded bodies).
* You want session context (`end_user.id`, metadata) auto-injected from
  the trusted thread — never from model-supplied args.

If you instead want one of the named scaffolds we already maintain
(Resend email, Linear ticket, Slack message, Twilio SMS, GitHub issue,
…), see [the builtins catalog](/api-reference/builtins) — those exist
specifically so you don't have to author the http-call config yourself.

## Anatomy of a valid config

Every `http-call` config has the same six knobs. Only `url` is required;
everything else has a sensible default.

| Field                | Type            | Required | Default   | What it is                                                                                                                                     |
| -------------------- | --------------- | -------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `url`                | string          | yes      | —         | Full endpoint URL. Must start with `http://` or `https://`. Supports template placeholders.                                                    |
| `method`             | string          | no       | `POST`    | One of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`.                                                                                                |
| `headers`            | string (JSON)   | no       | `"{}"`    | JSON-stringified object of header name → value. Values support placeholders.                                                                   |
| `body_template`      | string          | no       | `""`      | Request body. Either a JSON-template string or empty (no body). Sent as-is — set the right `content-type` header to match.                     |
| `secrets`            | string (JSON)   | no       | `"{}"`    | JSON-stringified object of secret name → value. Referenced via `{{secrets.X}}`. Stored AES-GCM encrypted; never returned by any read endpoint. |
| `lock_input_fields`  | string (JSON)   | no       | `"[]"`    | JSON-stringified array of input keys whose model-supplied values get **overridden** with trusted session values before the template renders.   |
| `response_max_bytes` | string (number) | no       | `"8192"`  | Cap on response bytes returned to the model. Truncated bodies append a clear `[…truncated…]` notice. Hard ceiling 1 MiB.                       |
| `timeout_ms`         | string (number) | no       | `"30000"` | Per-call timeout. Hard ceiling 60000.                                                                                                          |

<Note>
  Every config field is declared as `type: string` in the schema —
  including JSON objects/arrays and numbers. They're stored as strings
  so the dashboard form renders consistently; the gateway parses them at
  dispatch time. This means **`headers`, `secrets`, and
  `lock_input_fields` must be JSON-stringified**, not raw objects.
</Note>

## Template syntax

Placeholders use `{{...}}` mustache syntax. Five namespaces are available:

| Placeholder               | Source                                                  | Trustworthy?                  |
| ------------------------- | ------------------------------------------------------- | ----------------------------- |
| `{{config.X}}`            | The developer-supplied config (`url`, `method`, etc.)   | Yes — set at registration     |
| `{{secrets.X}}`           | The developer-supplied `secrets` object                 | Yes — encrypted at rest       |
| `{{args.X}}`              | Model-supplied tool input (matches your `input_schema`) | **No — model controls these** |
| `{{end_user.id}}`         | The thread's `end_user_id`                              | Yes — set at thread creation  |
| `{{end_user.metadata.X}}` | Any field from the thread's `metadata` object           | Yes — set at thread creation  |

Dot-paths drill into nested objects: `{{end_user.metadata.profile.email}}`
resolves to `metadata.profile.email`.

Missing paths render as the empty string. They do **not** error — this
keeps templates resilient when an optional metadata field is absent.

Object/array values are JSON-stringified when interpolated, so:

```json theme={null}
"body_template": "{\"items\": {{args.items}}}"
```

with `args.items = ["a", "b"]` renders as `{"items": ["a","b"]}`.

## Lock semantics — the security primitive

The single most important field is `lock_input_fields`. It's how you
prevent the classic prompt-injection abuse: the model being tricked into
filling a sensitive field with attacker-supplied content (e.g. setting
the `to:` of an email to a different person).

When you list a key in `lock_input_fields`, the gateway:

1. Drops whatever the model put in `args.<key>`.
2. Replaces it with `end_user.metadata.<same_key>` if present.
3. Or, for the canonical id keys (`user_id`, `end_user_id`,
   `customer_id`), falls back to `end_user.id`.
4. If neither source has a value, drops the key from `args` entirely
   (the request still fires, but with the field absent).

**Use it on every input key whose value MUST belong to the logged-in
end-user.** Examples:

* `to` (email) → locked to `end_user.metadata.email`
* `phone` (SMS) → locked to `end_user.metadata.phone`
* `customer_id` (account lookup) → locked to `end_user.id`
* `account_id`, `team_id` (anything tenant-scoped to the caller)

Skip it when the model legitimately should pick the value (e.g. an AI
support agent emailing a third-party supplier).

## A complete, valid config (annotated)

This is the single most important reference on the page. **Every
http-call registration looks like this:**

```json theme={null}
{
  "name": "lookup_subscription",
  "description": "Look up the customer's current subscription tier from our internal API. Use this when the user asks 'what plan am I on?' or anything billing-related.",
  "provider": "qlaud-builtin/http-call",
  "input_schema": {
    "type": "object",
    "properties": {
      "customer_id": {
        "type": "string",
        "description": "The customer's internal ID. Always equals the logged-in user."
      }
    },
    "required": ["customer_id"]
  },
  "config": {
    "method": "GET",
    "url": "https://api.acme.internal/customers/{{args.customer_id}}/subscription",
    "headers": "{\"authorization\": \"Bearer {{secrets.internal_token}}\", \"accept\": \"application/json\"}",
    "secrets": "{\"internal_token\": \"sk_internal_REAL_TOKEN_HERE\"}",
    "lock_input_fields": "[\"customer_id\"]"
  }
}
```

POST that JSON to `https://api.qlaud.ai/v1/tools` with your master-scope
key in the `Authorization` header and you have a working tool.

## Validation rules — what "valid" means

The gateway accepts a config if **all** of these hold:

1. `url` is present and starts with `http://` or `https://`.
2. `method` is one of `GET POST PUT PATCH DELETE` (or absent).
3. `headers`, `secrets`, `lock_input_fields` parse as JSON if non-empty.
4. `headers` parses to an object (not array, not primitive).
5. `secrets` parses to an object.
6. `lock_input_fields` parses to an array of strings.
7. `timeout_ms` and `response_max_bytes` are positive integers if set.

Invalid configs return a 400 from `POST /v1/tools` with a message
identifying the bad field.

At dispatch time (when the model invokes the tool), additional checks:

* Network errors → tool result with `is_error: true` and a clear message.
* Timeouts → tool result with `is_error: true` and the timeout duration.
* 4xx/5xx upstream → tool result with `is_error: true`, the response
  body included (truncated if huge), and the upstream status code.
* Successful 2xx → response body parsed as JSON if possible, else
  returned as plain text.

## Recipes — copy-paste these and adapt

Each recipe is a complete, valid `POST /v1/tools` body. Replace the
secret values + URLs with your own.

<Note>
  **When to skip http-call entirely.** Several common "send to logged-in
  user" cases have native lock support on a named scaffold and don't need
  http-call at all:

  * **Email to user** → use `qlaud-builtin/send-email` with
    `lock_to_session: "true"`. Forces `to` to `end_user.metadata.email`.
  * **SMS to user** → use `qlaud-builtin/twilio-send-sms` with
    `lock_to_session: "true"`. Forces `to` to `end_user.metadata.phone`.
  * **Linear / Zendesk / GitHub / Notion / Slack actions** → the recipient
    is your account, not an end-user. Tenant credentials at registration
    already bind the destination — no lock needed. Just use the named
    scaffold.

  Reach for `http-call` when wrapping **non-catalog endpoints** (your
  internal API, a third-party without an MCP server, GraphQL, custom
  auth) — that's the case the recipes below cover.
</Note>

### 1. Look up a customer in your internal database

```json theme={null}
{
  "name": "get_customer_account",
  "description": "Look up the logged-in customer's account details (plan, status, last_login) from our internal API.",
  "provider": "qlaud-builtin/http-call",
  "input_schema": {
    "type": "object",
    "properties": {},
    "required": []
  },
  "config": {
    "method": "GET",
    "url": "https://api.your-app.com/internal/users/{{end_user.id}}",
    "headers": "{\"authorization\": \"Bearer {{secrets.api_token}}\"}",
    "secrets": "{\"api_token\": \"sk_replace_me\"}"
  }
}
```

Notice no `lock_input_fields` is needed — the URL uses `{{end_user.id}}`
directly from the trusted session, so the model can't influence which
user is fetched.

### 2. Send a Slack alert to the customer's CSM channel

```json theme={null}
{
  "name": "alert_csm",
  "description": "Send a Slack alert to the customer's dedicated CSM channel. Use for escalations, churn risks, or urgent feedback.",
  "provider": "qlaud-builtin/http-call",
  "input_schema": {
    "type": "object",
    "properties": {
      "message": { "type": "string", "description": "What to post." }
    },
    "required": ["message"]
  },
  "config": {
    "method": "POST",
    "url": "https://slack.com/api/chat.postMessage",
    "headers": "{\"authorization\": \"Bearer {{secrets.slack_bot_token}}\", \"content-type\": \"application/json; charset=utf-8\"}",
    "body_template": "{\"channel\": \"{{end_user.metadata.csm_channel}}\", \"text\": \"From {{end_user.metadata.email}}: {{args.message}}\"}",
    "secrets": "{\"slack_bot_token\": \"xoxb-replace-me\"}"
  }
}
```

When you create the thread, pass the CSM channel + email as metadata:

```bash theme={null}
curl -X POST https://api.qlaud.ai/v1/threads \
  -H "Authorization: Bearer $QLAUD_KEY" \
  -d '{
    "end_user_id": "u_alice",
    "metadata": {
      "email": "alice@acme.com",
      "csm_channel": "C0123ABC456"
    }
  }'
```

### 3. File a Linear ticket — but use the named scaffold instead

For Linear specifically, use the named scaffold — it's friendlier:

```json theme={null}
{
  "name": "file_linear_bug",
  "provider": "qlaud-builtin/linear-create-issue",
  "config": {
    "api_key": "lin_api_REPLACE_ME",
    "team_id": "TEAM-ABC-UUID"
  }
}
```

Reach for `http-call` only when the named scaffold doesn't fit (custom
GraphQL fields, non-standard auth, etc.).

### 4. Lock down a "send refund" tool

When the action is high-stakes (money, account changes), lock everything
the model could influence:

```json theme={null}
{
  "name": "issue_refund",
  "description": "Issue a partial refund to the logged-in customer. Use only when explicitly authorized by them.",
  "provider": "qlaud-builtin/http-call",
  "input_schema": {
    "type": "object",
    "properties": {
      "customer_id": { "type": "string" },
      "amount_cents": { "type": "integer", "minimum": 1, "maximum": 50000 },
      "reason": { "type": "string" }
    },
    "required": ["customer_id", "amount_cents", "reason"]
  },
  "config": {
    "method": "POST",
    "url": "https://api.your-app.com/internal/refunds",
    "headers": "{\"authorization\": \"Bearer {{secrets.api_token}}\", \"content-type\": \"application/json\"}",
    "body_template": "{\"customer_id\":\"{{args.customer_id}}\",\"amount_cents\":{{args.amount_cents}},\"reason\":\"{{args.reason}}\"}",
    "secrets": "{\"api_token\": \"sk_replace_me\"}",
    "lock_input_fields": "[\"customer_id\"]"
  }
}
```

The `customer_id` is locked to `end_user.id` — the model can suggest
"refund Bob $10" but the actual call always targets the logged-in
customer. The `amount_cents` schema additionally caps the refund at
$500.

### 5. GraphQL query

```json theme={null}
{
  "name": "search_internal_docs",
  "description": "Search our internal documentation knowledge base.",
  "provider": "qlaud-builtin/http-call",
  "input_schema": {
    "type": "object",
    "properties": {
      "query": { "type": "string" }
    },
    "required": ["query"]
  },
  "config": {
    "method": "POST",
    "url": "https://api.your-app.com/graphql",
    "headers": "{\"authorization\": \"Bearer {{secrets.api_token}}\", \"content-type\": \"application/json\"}",
    "body_template": "{\"query\":\"query Search($q: String!) { docs(query: $q) { id title snippet } }\",\"variables\":{\"q\":\"{{args.query}}\"}}",
    "secrets": "{\"api_token\": \"sk_replace_me\"}"
  }
}
```

### 6. Form-encoded API (e.g. a webhook hook your app exposes)

```json theme={null}
{
  "name": "trigger_workflow",
  "description": "Kick off a long-running workflow on the customer's behalf.",
  "provider": "qlaud-builtin/http-call",
  "input_schema": {
    "type": "object",
    "properties": {
      "workflow_name": { "type": "string" }
    },
    "required": ["workflow_name"]
  },
  "config": {
    "method": "POST",
    "url": "https://api.your-app.com/workflows/trigger",
    "headers": "{\"authorization\": \"Bearer {{secrets.api_token}}\", \"content-type\": \"application/x-www-form-urlencoded\"}",
    "body_template": "user_id={{end_user.id}}&workflow={{args.workflow_name}}",
    "secrets": "{\"api_token\": \"sk_replace_me\"}"
  }
}
```

## Things that look right but aren't

These are the patterns we see most often in mistaken configs. **If you
generate a config and notice any of these, fix them before saving.**

❌ **Raw object instead of JSON-stringified for `headers`:**

```json theme={null}
"headers": { "authorization": "Bearer xyz" }   ← invalid
```

✅ Stringify it:

```json theme={null}
"headers": "{\"authorization\": \"Bearer xyz\"}"
```

❌ **Quoting an object placeholder inside the body template:**

```json theme={null}
"body_template": "{\"items\": \"{{args.items}}\"}"
```

This produces `{"items":"["a","b"]"}` (string-of-array). Drop the quotes:

```json theme={null}
"body_template": "{\"items\": {{args.items}}}"
```

❌ **Forgetting `content-type` on a POST/PUT/PATCH body:**

The body is sent as-is. If you intended JSON, include
`"content-type": "application/json"` in headers — most APIs reject
bodies without it.

❌ **Putting secrets directly in `headers`:**

```json theme={null}
"headers": "{\"authorization\": \"Bearer sk_REAL_KEY\"}"   ← exposed
```

The `headers` blob is visible in dashboard read-paths to the developer.
Move secrets into `secrets` and reference them:

```json theme={null}
"headers": "{\"authorization\": \"Bearer {{secrets.api_key}}\"}",
"secrets": "{\"api_key\": \"sk_REAL_KEY\"}"
```

Only `secrets` values are AES-GCM encrypted at rest and never returned
by any read endpoint.

❌ **Using `lock_input_fields` for a key the schema doesn't declare:**

The locked key has no effect (there's nothing to override) and no
fallback either — fix the typo or remove the lock.

❌ **Using `{{end_user.email}}` when there's no `email` in metadata:**

`end_user.metadata.email` only exists if you passed it when creating the
thread:

```bash theme={null}
curl -X POST https://api.qlaud.ai/v1/threads \
  -d '{"end_user_id":"u_alice","metadata":{"email":"alice@acme.com"}}'
```

Without it, the placeholder renders as empty string.

## How to test a tool before going live

1. Register the tool with `POST /v1/tools` (a 201 confirms config-time
   validation passed).
2. Create a test thread with an `end_user_id` and the metadata your
   template needs.
3. Send a message that should trigger the tool, with `tools: [<tool_id>]`.
4. Inspect the assistant's response — if the tool fired, the
   `tool_use` and `tool_result` blocks are visible in
   `GET /v1/threads/<id>/messages`.
5. Iterate on the config (`PATCH /v1/tools/<id>/config`, coming soon)
   or just revoke + re-register (`DELETE /v1/tools/<id>` then re-POST).

## When to use this vs. the alternatives

| You want to…                                                        | Use                                                                                       |
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Wrap an internal/3rd-party REST endpoint with no infra on your side | `qlaud-builtin/http-call` (this page)                                                     |
| Send email / SMS / Slack / file ticket via a popular SaaS           | The named scaffold for that SaaS — see [/api-reference/builtins](/api-reference/builtins) |
| Let each end-user OAuth into their own Notion/GitHub/Stripe         | A per-user MCP server — see [/api-reference/mcp-catalog](/api-reference/mcp-catalog)      |
| Run arbitrary logic that needs to live on your infrastructure       | A webhook tool — see [/api-reference/tools](/api-reference/tools)                         |

## Errors

| Status | Meaning                                                                                                                                               |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | Config missing `url`, bad URL scheme, `headers`/`secrets`/`lock_input_fields` not valid JSON of the expected shape, or numeric field is non-positive. |
| 401    | Missing / revoked qlk key.                                                                                                                            |
| 403    | Caller used a per-user (standard-scope) key. `/v1/tools` requires a master-scope key.                                                                 |
| 409    | A non-revoked tool with the same `name` already exists.                                                                                               |
| 503    | Gateway is missing the `TOOL_CONFIG_ENC_KEY` operator secret. Built-ins (including http-call) can't be registered until it's set.                     |
