Custom tools
The Tool Store: teach the agent to call the workspace's own API with no code — a request template plus a parameter list.
Download the full API pública projection: openapi.json.
Configure the agent that talks explains this resource in prose, with runnable examples.
| Method | Path | What it does |
|---|---|---|
GET | /custom-tools | List the workspace’s custom tools |
POST | /custom-tools | Create a custom tool |
DELETE | /custom-tools/{id} | Delete a custom tool |
GET | /custom-tools/{id} | Fetch one custom tool |
PATCH | /custom-tools/{id} | Update a custom tool |
PUT | /custom-tools/{id} | Replace a custom tool |
POST | /custom-tools/{id}/infer-schema | Derive a response schema from the saved sample |
POST | /custom-tools/{id}/test | Invoke the tool with sample arguments |
GET /custom-tools
List the workspace’s custom tools
Every tool with its parameters, auth configuration and request template, plus a total. Credential VALUES are not here — auth_config carries a credential_id reference, not a secret.
curl https://api.vitrinadev.com/api/v1/custom-tools \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"id": "a7a7a7a7-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "consultar_clima",
"description": "Consulta el clima actual de una ciudad",
"kind": "custom_http",
"enabled": true,
"parameters": [
{
"name": "ciudad",
"type": "string",
"description": "Nombre de la ciudad",
"required": true
}
],
"auth_type": "none",
"auth_config": {},
"request_template": {
"method": "GET",
"url": "https://api.example.com/weather?city=${param.ciudad}",
"query": [],
"headers": [],
"body_kind": "none"
},
"response_schema": null,
"sample_response": null,
"timeout_ms": 15000,
"volatile": false,
"created_at": "2026-09-15T12:00:00.000Z",
"updated_at": "2026-09-15T12:00:00.000Z"
}
],
"meta": {
"total": 1
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /custom-tools
Create a custom tool
name is the function name the model sees and must be snake_case; it is unique per workspace, and a collision is a 409. description is what the model reads to decide whether to call the tool, so it is required and is prompt text rather than documentation.
Each entry in parameters becomes an argument the model can supply, and its description is likewise read by the model. Their values reach the request as $\{param.NAME\} placeholders anywhere in the URL, query, headers or body.
Any auth_type other than none requires auth_config.credential_id pointing at a stored credential (/tool-credentials). Secrets are never written inline here — the template references them as $\{secret.NAME\} and the runtime substitutes them.
The template is validated on the way in: the URL must be absolute http(s), a GET may not carry a body, and a json body must parse once placeholders are substituted. timeout_ms defaults to 15.000 and is capped at 60.000. Answers 201.
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | yes | mín. 2, máx. 64, patrón ^[a-z][a-z0-9_]{1,63}$ |
description | string | yes | mín. 1, máx. 500 |
parameters | object[] | — | por defecto [] |
auth_type | none \ | api_key \ | bearer \ |
auth_config | object | — | por defecto {} |
request_template | object | yes | |
response_schema | any | — | |
timeout_ms | integer | — | ≥ 500, ≤ 60000, por defecto 15000 |
enabled | boolean | — | por defecto true |
volatile | boolean | — | por defecto false |
curl -X POST https://api.vitrinadev.com/api/v1/custom-tools \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "consultar_clima",
"description": "Consulta el clima actual de una ciudad",
"parameters": [
{
"name": "ciudad",
"type": "string",
"description": "Nombre de la ciudad",
"required": true
}
],
"request_template": {
"method": "GET",
"url": "https://api.example.com/weather?city=${param.ciudad}"
}
}'Example response (201)
{
"data": {
"id": "a7a7a7a7-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "consultar_clima",
"description": "Consulta el clima actual de una ciudad",
"kind": "custom_http",
"enabled": true,
"parameters": [
{
"name": "ciudad",
"type": "string",
"description": "Nombre de la ciudad",
"required": true
}
],
"auth_type": "none",
"auth_config": {},
"request_template": {
"method": "GET",
"url": "https://api.example.com/weather?city=${param.ciudad}",
"query": [],
"headers": [],
"body_kind": "none"
},
"response_schema": null,
"sample_response": null,
"timeout_ms": 15000,
"volatile": false,
"created_at": "2026-09-15T12:00:00.000Z",
"updated_at": "2026-09-15T12:00:00.000Z"
}
}Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /custom-tools/{id}
Delete a custom tool
Removes the tool and its parameters. Past tool_invocation audit rows survive — the record of what was called stays even when the tool itself is gone.
Agents wired to the tool lose it. Setting enabled: false is the reversible way to take a tool out of service.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl -X DELETE https://api.vitrinadev.com/api/v1/custom-tools/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429
GET /custom-tools/{id}
Fetch one custom tool
The full definition including sample_response and response_schema when they have been captured.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl https://api.vitrinadev.com/api/v1/custom-tools/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"id": "a7a7a7a7-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "consultar_clima",
"description": "Consulta el clima actual de una ciudad",
"kind": "custom_http",
"enabled": true,
"parameters": [
{
"name": "ciudad",
"type": "string",
"description": "Nombre de la ciudad",
"required": true
}
],
"auth_type": "none",
"auth_config": {},
"request_template": {
"method": "GET",
"url": "https://api.example.com/weather?city=${param.ciudad}",
"query": [],
"headers": [],
"body_kind": "none"
},
"response_schema": null,
"sample_response": null,
"timeout_ms": 15000,
"volatile": false,
"created_at": "2026-09-15T12:00:00.000Z",
"updated_at": "2026-09-15T12:00:00.000Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
PATCH /custom-tools/{id}
Update a custom tool
Applies only the fields present and leaves everything else as it is — this is the safe way to change one thing. Prefer it over PUT unless you genuinely hold the whole object.
parameters and request_template are each replaced wholesale when sent, never merged, so a parameter list must be sent complete. Any auth_type other than none requires auth_config.credential_id pointing at a stored credential (/tool-credentials). Secrets are never written inline here — the template references them as $\{secret.NAME\} and the runtime substitutes them.
Changes take effect on the agent’s next call; nothing needs republishing.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | — | mín. 2, máx. 64, patrón ^[a-z][a-z0-9_]{1,63}$ |
description | string | — | mín. 1, máx. 500 |
parameters | object[] | — | por defecto [] |
auth_type | none \ | api_key \ | bearer \ |
auth_config | object | — | por defecto {} |
request_template | object | — | |
response_schema | any | — | |
timeout_ms | integer | — | ≥ 500, ≤ 60000, por defecto 15000 |
enabled | boolean | — | por defecto true |
volatile | boolean | — | por defecto false |
curl -X PATCH https://api.vitrinadev.com/api/v1/custom-tools/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"description": "Consulta el clima actual (°C) de una ciudad chilena"
}'Example response (200)
{
"data": {
"id": "a7a7a7a7-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "consultar_clima",
"description": "Consulta el clima actual (°C) de una ciudad chilena",
"kind": "custom_http",
"enabled": true,
"parameters": [
{
"name": "ciudad",
"type": "string",
"description": "Nombre de la ciudad",
"required": true
}
],
"auth_type": "none",
"auth_config": {},
"request_template": {
"method": "GET",
"url": "https://api.example.com/weather?city=${param.ciudad}",
"query": [],
"headers": [],
"body_kind": "none"
},
"response_schema": null,
"sample_response": null,
"timeout_ms": 15000,
"volatile": false,
"created_at": "2026-09-15T12:00:00.000Z",
"updated_at": "2026-09-15T12:00:00.000Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /custom-tools/{id}
Replace a custom tool
A true REPLACE, and the difference from PATCH is easy to get wrong. This endpoint validates against the CREATE schema, so every field you omit is filled with its default and written — omitting parameters empties the parameter list, omitting timeout_ms resets it to 15.000, and omitting enabled re-enables the tool.
Omitting auth_type sets it to none and clears auth_config, so the tool silently stops authenticating and starts calling the upstream API unauthenticated. Send the complete object, or use PATCH for partial edits.
The one field that does NOT reset is response_schema, which has no default and is left alone when absent.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | yes | mín. 2, máx. 64, patrón ^[a-z][a-z0-9_]{1,63}$ |
description | string | yes | mín. 1, máx. 500 |
parameters | object[] | — | por defecto [] |
auth_type | none \ | api_key \ | bearer \ |
auth_config | object | — | por defecto {} |
request_template | object | yes | |
response_schema | any | — | |
timeout_ms | integer | — | ≥ 500, ≤ 60000, por defecto 15000 |
enabled | boolean | — | por defecto true |
volatile | boolean | — | por defecto false |
curl -X PUT https://api.vitrinadev.com/api/v1/custom-tools/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "consultar_clima",
"description": "Consulta el clima actual de una ciudad",
"parameters": [
{
"name": "ciudad",
"type": "string",
"description": "Nombre de la ciudad",
"required": true
}
],
"request_template": {
"method": "GET",
"url": "https://api.example.com/weather?city=${param.ciudad}"
}
}'Example response (200)
{
"data": {
"id": "a7a7a7a7-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "consultar_clima",
"description": "Consulta el clima actual de una ciudad",
"kind": "custom_http",
"enabled": true,
"parameters": [
{
"name": "ciudad",
"type": "string",
"description": "Nombre de la ciudad",
"required": true
}
],
"auth_type": "none",
"auth_config": {},
"request_template": {
"method": "GET",
"url": "https://api.example.com/weather?city=${param.ciudad}",
"query": [],
"headers": [],
"body_kind": "none"
},
"response_schema": null,
"sample_response": null,
"timeout_ms": 15000,
"volatile": false,
"created_at": "2026-09-15T12:00:00.000Z",
"updated_at": "2026-09-15T12:00:00.000Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /custom-tools/{id}/infer-schema
Derive a response schema from the saved sample
Generates a JSON Schema from the stored sample_response, saves it as the tool’s response_schema, and returns the updated tool. Takes no body — it works from what is already stored, so run /\{id\}/test with save_sample: true first. Without a sample it is a 400 saying so.
Inference sees ONE response. A field that happened to be null, or an array that happened to be empty, is typed from that single example, so the result is a starting point to edit rather than a finished contract.
The schema is used to set schema_valid on future invocations. It REPORTS a mismatch; it does not reject the response or stop the agent from using it.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl -X POST https://api.vitrinadev.com/api/v1/custom-tools/<id>/infer-schema \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"id": "a7a7a7a7-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "consultar_clima",
"description": "Consulta el clima actual de una ciudad",
"kind": "custom_http",
"enabled": true,
"parameters": [
{
"name": "ciudad",
"type": "string",
"description": "Nombre de la ciudad",
"required": true
}
],
"auth_type": "none",
"auth_config": {},
"request_template": {
"method": "GET",
"url": "https://api.example.com/weather?city=${param.ciudad}",
"query": [],
"headers": [],
"body_kind": "none"
},
"response_schema": {
"type": "object",
"properties": {
"ciudad": {
"type": "string"
},
"temp_c": {
"type": "number"
},
"condicion": {
"type": "string"
}
}
},
"sample_response": null,
"timeout_ms": 15000,
"volatile": false,
"created_at": "2026-09-15T12:00:00.000Z",
"updated_at": "2026-09-15T12:00:00.000Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /custom-tools/{id}/test
Invoke the tool with sample arguments
This performs the real request. It is a tester, not a dry run: the configured URL is called with the real credential through the same runtime the agent uses, so testing a tool whose template is a POST or DELETE creates or destroys data on the upstream system exactly as a live call would. There is no sandbox mode.
Answers 200 with the outcome, pass or fail: \{ ok, status, latency_ms, body, body_truncated, error, redacted_request, schema_valid, invocation_id \}. Read ok, not the HTTP status — a timeout, a refused SSRF target or a 500 from the upstream all come back as a 200 here with ok: false.
ok is true only for a 2xx with no transport error. Redirects are not followed, so a 301 is reported as-is and counts as a failure. schema_valid is null when the tool has no response_schema.
redacted_request shows exactly what was sent with credential-derived values replaced — the right thing to show an author debugging a template, and safe to display. The same snapshot is what lands in the audit row, tagged triggered_by: "tester".
save_sample: true stores the response as sample_response for /infer-schema, but only when the call succeeded — a failed test never overwrites a good sample. Requires functions:write, because it can write both the sample and the upstream system.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
sample_args | object | — | por defecto {} |
save_sample | boolean | — | por defecto false |
curl -X POST https://api.vitrinadev.com/api/v1/custom-tools/<id>/test \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"arguments": {
"ciudad": "Santiago"
}
}'Example response (200)
{
"data": {
"ok": true,
"status": 200,
"latency_ms": 214,
"body": {
"ciudad": "Santiago",
"temp_c": 18,
"condicion": "despejado"
},
"body_truncated": false,
"error": null,
"redacted_request": {
"method": "GET",
"url": "https://api.example.com/weather?city=Santiago"
},
"schema_valid": null,
"invocation_id": "a7a7a7a7-3000-4000-8000-000000000001"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429