# mapis — mock APIs as a service

> mapis (`https://mapis.stdcht.io`) lets you define a fake HTTP backend with a few REST calls and use it at once: no repo, no deploy. It exists so Studio Chat sales demos can have transactional api-tools (look up a customer, quote and execute a transfer, open a claim, list records) without writing a real backend. This file is the complete operating guide, written for an agent that will build and maintain mocks.

## 1. When to use it

Use mapis when a demo or a test needs an API that answers coherently: a lookup by id, a POST that returns an id and a status, a list to browse, a public page to open on a phone. Do not use it when the demo depends on real business rules transcribed from a client's terms and conditions across many interacting entities — that is a `customer-apis` vendor (a real TypeScript backend in `studiochat-workers/customer-apis`). Start with mapis; escalate only if the mock cannot express the behaviour.

## 2. Mental model

- **Project** — one mock API. It has a `slug`, a `name`, its own API key (`mk_…`), a `timezone`, and a request log. Its mocks live at `https://mapis.stdcht.io/<slug>/…`.
- **Endpoint** — `method` + `path` pattern (`/clientes/:dni`) + an ordered list of **response rules**. Each rule has an optional `when` condition, a `status`, `headers`, a templated `body`, an optional `delay_ms` and an optional `seed`. The first rule whose condition holds answers. An optional `request` schema validates the incoming body/query (400 on mismatch).
- **Resource** — a name plus a record `schema` and/or `seed` records. You get a complete CRUD REST resource: `GET /<name>` (pagination, filters, search, sort), `GET|PUT|PATCH|DELETE /<name>/:id`, `POST /<name>`. Data persists until you reset. An explicit endpoint on the same path wins over the generated route.

Two URL spaces, two credentials:

| Space | URL | Credential |
|---|---|---|
| Management API | `https://mapis.stdcht.io/api/…` | `X-Admin-Key: <ADMIN_KEY>` (also accepted as `X-API-Key` or `Authorization: Bearer`). The admin key also opens every mock. |
| Mocks | `https://mapis.stdcht.io/<slug>/…` | The project's key, in the project's header (default `X-API-Key`) or as `Authorization: Bearer`. Projects can have `auth.mode: "none"`; endpoints and resources can be `public`. |

Project keys are demo credentials and are readable through the management API on purpose (Studio Chat masks stored headers, so this is where you read them back from).

All bodies are JSON. Errors are `{ "error": "<code>", "message": "…", … }` with 400 / 401 / 404 / 405 / 409 / 413 / 503.

## 3. Management API reference

| Call | Body / notes | Returns |
|---|---|---|
| `GET /api/projects` | — | `[summary]` — `slug, name, description, base_url, auth {mode, header, key}, timezone, log_limit, endpoints, resources, records, created_at, updated_at` |
| `POST /api/projects` | `{ name, slug?, description?, auth?, timezone?, log_limit? }`. `slug` defaults to a slugified `name` (2-40 chars `[a-z0-9-]`; reserved: `api health import projects docs static admin mapis __*`). `auth`: `{ "mode": "api_key", "header"?: "X-API-Key", "key"?: "…" }` or `{ "mode": "none" }` (key generated when omitted). | 201 summary (with the key) |
| `GET /api/projects/:slug` | — | summary + `endpoints[]` + `resources[]` |
| `PUT /api/projects/:slug` | A full definition: `{ project: { name, description?, auth?, timezone?, log_limit? }, endpoints: [...], resources: [...] }` (the shape `export` returns). **Declarative upsert**: creates (201) or replaces everything (200). Keeps the existing key unless `project.auth.key` is given. Validates everything before writing; a bad definition leaves the project intact. | summary + `created`, `seeded` |
| `PATCH /api/projects/:slug` | Any of `name, description, auth, timezone, log_limit` | summary |
| `DELETE /api/projects/:slug` | — | `{ deleted }` |
| `POST /api/projects/:slug/rotate-key` | — | summary with the new key |
| `POST /api/projects/:slug/reset` | `?keep_logs=1` to keep the log | `{ ok, resources: { name: count }, logs_cleared }` — every resource re-seeded, `seq()` counters restarted |
| `GET /api/projects/:slug/logs?limit=50&since=<seq>` | newest first | `[{ seq, ts, method, path, status, matched, duration_ms, delay_ms, request: { headers, query, body }, response: { headers, body } }]`. `matched` is `endpoint:<id>`, `resource:<name>`, `none`, `auth` or `reset`. Credentials are masked, bodies cut at 4000 chars. |
| `DELETE /api/projects/:slug/logs` | — | `{ cleared }` |
| `GET /api/projects/:slug/export?include_key=1` | — | the full definition (`version: 1, project, endpoints, resources`) |
| `POST /api/projects/import?replace=1` | a definition; the slug comes from `project.slug` | 201 / 200 summary |
| `GET /api/projects/:slug/openapi.json` | — | OpenAPI 3.0 document of the mock |
| `GET /api/projects/:slug/studiochat-tools` | — | `{ project, base_url, note, tools: [manage_api_tools payloads] }` (section 8) |
| `GET /api/projects/:slug/endpoints` · `POST` · `PUT` | POST takes one endpoint **or an array**; PUT replaces all with an array | endpoint(s) |
| `GET|PUT|PATCH|DELETE /api/projects/:slug/endpoints/:id` | PUT and PATCH both update; fields you omit keep their value | endpoint |
| `GET /api/projects/:slug/resources` · `POST` | POST takes one resource or an array | resource(s) with `records` count |
| `GET|PUT|PATCH|DELETE /api/projects/:slug/resources/:name` | Changing `seed`, `schema` or `count` (or sending `reseed: true`) re-seeds the records; other changes keep them | resource |
| `GET|PUT|DELETE /api/projects/:slug/resources/:name/records` | PUT replaces the data with an array of records | records / `{ records }` / `{ cleared }` |
| `GET /api/helpers` | — | template helper names, condition operators, date formats |
| `POST /api/render` | `{ template, ctx?, seed?, timezone? }` — dry-run any template, nothing stored | `{ result }` or 400 `template_error` |
| `POST /<slug>/__reset` | with the **project** key (mock space) | same as reset |

## 4. Endpoint definition

```json
{
  "method": "POST",
  "path": "/transferencias",
  "name": "transferir",
  "description": "Ejecuta una transferencia ya confirmada por el cliente.",
  "public": false,
  "request": {
    "body": { "required": ["cbu", "monto"], "properties": { "cbu": { "type": "string", "minLength": 22, "maxLength": 22, "description": "CBU destino" }, "monto": { "type": "number", "minimum": 1 } } },
    "query": { "properties": { "dry_run": { "type": "boolean" } } }
  },
  "responses": [
    { "when": { "body.monto": { "gt": 500000 } }, "status": 422, "body": { "error": "supera_limite", "limite_diario": 500000 } },
    { "when": { "body.cbu": { "starts_with": "999" } }, "status": 404, "body": { "error": "cbu_inexistente" } },
    { "status": 201, "delay_ms": 800, "headers": { "x-request-id": "{{ uuid }}" },
      "body": { "id": "TRF-{{ pad(seq(\"trf\", 900001), 6) }}", "estado": "PENDIENTE", "monto": "{{ num(body.monto) }}",
                "fecha": "{{ now() }}", "acreditacion_estimada": "{{ date(\"+1d\", \"dmy\") }}" } }
  ]
}
```

Rules:

- `method`: `GET POST PUT PATCH DELETE HEAD OPTIONS ANY`. An exact method beats `ANY`; `HEAD` is served by `GET`.
- `path`: `/users/:id` or `/users/{id}` for one segment; `/files/*` (params.rest) or `/files/*name` for the remainder. Static segments beat params, params beat wildcards. Trailing slash ignored. A path that matches with another method answers 405 with `Allow`.
- `responses`: tried in order. A rule without `when` is the default and **must be last** (a default earlier than last is rejected at save time). If every rule has a `when` and none holds, the mock answers 404 `no_matching_response`. Shorthands: `"response": { … }` for one rule, or `status`/`body`/`headers`/`delay_ms` flattened at the top level.
- `status` defaults to 200. A JSON `body` (object/array) is rendered recursively and sent as `application/json`; a string body is rendered and sent as `text/plain` unless a `content-type` header says otherwise (that is how you mock a public HTML page). No `body` → empty response.
- `delay_ms` caps at 25000 (Studio Chat cuts tools at 30 s).
- `public: true` skips the project's auth for that endpoint.
- Names must be valid Studio Chat tool names if you want them reused as such: `^[a-zA-Z0-9_.-]{1,64}$`.

### 4.1 Conditions (`when`)

Object = AND of `"path": matcher` entries; `any` (OR), `all`, `not` combine; a bare array is an OR.

```json
{ "params.dni": "30123456" }                              loose equality (40000 == "40.000", true == "true", null == missing)
{ "body.monto": { "gt": 100000, "lte": 500000 } }         eq ne gt gte lt lte
{ "query.estado": { "in": ["ABIERTO", "EN_ANALISIS"] } }  in nin
{ "body.email": { "exists": true } }                      exists empty truthy
{ "body.cbu": { "starts_with": "285" } }                  contains starts_with ends_with matches(regex) type
{ "any": [ { "params.dni": "1" }, { "params.dni": "2" } ], "not": { "body.confirm": true } }
```

Paths: `params.*`, `query.*`, `body.*` (dots and indexes: `body.items.0.sku`), `headers.*` (lower-case names), `method`, `path`.

### 4.2 Templates (`{{ … }}`)

Work in bodies, headers, `seed`, resource schemas and seeds.

- Context: `params`, `query`, `body`, `headers`, `method`, `path`, `url`, `base_url` (origin + slug, for links to public pages), `project`, `now` (ISO), `today` (YYYY-MM-DD in the project timezone), `timestamp`. While seeding a resource: `index`, `i` (= index + 1), `resource`.
- **A string that is exactly one expression keeps the value's type**: `"saldo": "{{ int(1, 9) }}"` renders a number, `"items": "{{ body.items }}"` an array. Embedded expressions interpolate as text: `"Pedido {{ params.id }} x{{ body.qty }}"`.
- Arithmetic `+ - * / %` and parentheses; `+` concatenates when a side is a string. Missing paths are `undefined`: dropped from JSON objects, empty in text, never an error. A helper with no arguments may drop its parentheses: `{{ uuid }}`.
- Strings use `"` or `'` (escape with `\`). Identifiers may contain dashes (`headers.x-request-id`); write `a - b` with spaces to subtract variables.

Helpers:

| Group | Helpers |
|---|---|
| ids | `uuid()` · `id(prefix, digits)` random digits · `seq(name, start)` persisted per-project counter, restarts on reset · `seq_current(name)` the counter's value without incrementing (reuse an id twice in one body: fields render in order) · `hash(key)` · `derive(key, min, max)` deterministic int from a key · `derive_pick(key, a, b, …)` · `derive_bool(key, p)` |
| random | `int(min, max)` · `float(min, max, decimals)` · `amount(min, max)` 2 decimals · `bool(p)` · `pick(a, b, …)` / `pick(array)` · `sample(array, n)` · `chance(p, a, b)` |
| fake data (Argentine flavour) | `name()` `first_name()` `last_name()` `email(name?)` `phone()` `dni()` `cuit()` `cbu()` `alias()` `bank()` `company()` `city()` `province()` `street()` `address()` `word()` `words(n)` `sentence(n?)` `paragraph(n?)` |
| dates | `now(format?)` · `today()` · `date(offset, format?)` · `format_date(value, format)` · `shift(value, offset, format?)` · `days_between(a, b)` · `timestamp()`. Offsets: `+2d -3h +1w +1M +1y +30m`, `"+1d +3h"`, or a bare number of days. Formats: `iso` (default), `date`, `datetime`, `time`, `dmy`, `human`, `unix`, `ms`, `weekday`, `month`, `year` — calendar formats use the project's timezone (default America/Argentina/Buenos_Aires) |
| strings | `upper lower capitalize title trim slug concat str pad(v, n, ch) pad_end replace(s, a, b) split join(list, sep) substr truncate(s, n) starts_with ends_with includes matches mask(s, visible)` |
| numbers | `num(x)` parses es-AR and en-US strings (`"40.000"` → 40000, `"1.234,56"` → 1234.56) · `int_of round(x, d) floor ceil abs min max sum(list, field?) avg(list, field?)` · `money(x, currency?)` → `$ 12.345,50` · `format_number(x, decimals)` · `percent(part, total, decimals)` |
| collections & logic | `len count first last keys values get(obj, path, fallback) pluck(list, field) find(list, field, value) where(list, field, value) range(n)` · `default(x, fallback) coalesce(…) if(cond, a, b) not and or eq ne gt gte lt lte exists empty typeof` · `json(x)` `parse_json(s)` |

**Determinism.** Random helpers read the response's random source. `"seed": "{{ params.dni }}"` on a rule seeds it with the rendered value: the same DNI always renders the same customer, so the deck never contradicts itself between calls. Without `seed` every call rolls anew. `derive(key, min, max)` needs no seed. Resource generation is seeded by `<slug>:<resource>:<index>`, so `reset` reproduces the exact same records.

**Save-time validation.** Path syntax, methods, status codes, condition operators, request schemas and every template (unknown helper names, unbalanced `{{ }}`, parentheses, quotes) are checked when you save → 400 with the reason. A template that fails at request time (e.g. an invalid date offset built from input) answers 500 `template_error` with the message and shows up in the log.

### 4.3 Request schema (`request.body`, `request.query`)

JSON Schema subset: `type` (or array of types), `properties`, `required`, `additionalProperties`, `items`, `enum`, `const`, `minimum`/`maximum`/`exclusiveMinimum`/`exclusiveMaximum`, `minLength`/`maxLength`, `pattern`, `format` (`email`, `date`, `date-time`, `uuid`, `time`), `minItems`/`maxItems`, `nullable`, `description`. `properties`/`required` imply `object`; `items` implies `array`. **Numbers and booleans are checked loosely** (`"40000"` satisfies `number`, `"true"` satisfies `boolean`) because Studio Chat's api-tools send model-filled values as strings. A mismatch answers `400 { "error": "validation_error", "details": [{ "path": "body.monto", "message": "must be >= 1" }] }`. Field `description`s are reused as the model hints in `studiochat-tools`, so write them.

## 5. Resource definition

```json
{
  "name": "reclamos",
  "description": "Reclamos de clientes",
  "id_field": "id",
  "wrap": false,
  "public": false,
  "readonly": false,
  "seed": [
    { "id": "RCL-00001", "dni": "30123456", "motivo": "cargo duplicado", "monto": 6000, "estado": "ABIERTO", "creado": "{{ date(\"-2d\", \"date\") }}" }
  ],
  "count": 8,
  "schema": {
    "id": "RCL-{{ pad(seq(\"rcl\", 1001), 5) }}",
    "dni": "{{ dni() }}",
    "motivo": "{{ pick(\"cargo duplicado\", \"tarjeta no llegó\", \"cobro desconocido\") }}",
    "monto": "{{ int(1000, 90000) }}",
    "estado": "{{ pick(\"ABIERTO\", \"EN_ANALISIS\", \"RESUELTO\") }}",
    "creado": "{{ date(\"-\" + int(1, 30) + \"d\", \"date\") }}"
  }
}
```

- `seed`: curated records, templated at seed time (write dates as offsets so the deck never expires). `count`: generated records from `schema` on top of the seed (default 10 when there is no seed, 0 when there is one; `count > 0` needs a `schema`). A record without `id_field` gets `1`, `2`, …
- `schema` doubles as the defaults for `POST`: fields the body leaves out are rendered (id, timestamps, a default status).
- Generated routes under `/<slug>`:
  - `GET /reclamos` → array (or `{ data, total, page, limit, pages }` when `wrap`), header `X-Total-Count`. Query: `page` (1-based), `limit` (default 50, max 500), `offset`, `sort`, `order=asc|desc`, `search`/`q` (substring over every value), `fields=a,b` (projection), and **any other key filters by field**: `estado=ABIERTO` (repeat the key for OR), `monto_gte`, `monto_lte`, `monto_gt`, `monto_lt`, `estado_ne`, `motivo_like` (substring, case-insensitive).
  - `GET /reclamos/:id` → the record or 404.
  - `POST /reclamos` → 201 + record (+ `Location`); 409 on a duplicate id; `readonly` → 405.
  - `PUT /reclamos/:id` replaces (id kept) · `PATCH` merges · `DELETE` → `{ deleted: true, id, record }`.
- Nested resources are not generated; define an explicit endpoint (`GET /clientes/:dni/reclamos` with a templated body or a rule per DNI) when needed.

## 6. Recipes for demos

1. **Curated deck + coherent fallback.** One rule per scripted id (`{ "when": { "params.dni": "30123456" }, "body": {…} }`), then a derived rule for any well-formed id (`{ "when": { "params.dni": { "matches": "^\\d{7,8}$" } }, "seed": "{{ params.dni }}", "body": { "nombre": "{{ name() }}", "saldo": "{{ int(1000, 90000) }}" } }`), then a 404 default. An id invented in the room still gets a stable, plausible answer.
2. **Dates that never expire.** Always `{{ date("+2d", "date") }}`, `{{ date("-30d", "dmy") }}`, never absolute dates. Calendar formats follow the project timezone, so "tomorrow" flips at midnight in Buenos Aires, not UTC.
3. **Two-phase actions (quote, then execute).** Either two endpoints (`POST /transferencias/cotizar` and `POST /transferencias`) or one endpoint whose first rule is `{ "when": { "body.confirm": true }, … }` with a 400 `confirmacion_requerida` default. In Studio Chat, create two tools with `confirm` hardcoded (`false` / `true`) so the quoting tool is structurally unable to mutate.
4. **The "no" scenes.** Rules that answer 422/403/409 with an `error` code, a human `message` and, when possible, an alternative (`"alternativa": "…"`). The assistant needs the reason to deliver a good refusal.
5. **Latency and failure drills.** `delay_ms` on a rule; a rule with `{ "when": { "headers.x-demo-fail": "1" } , "status": 503 }` you can trigger by hand.
6. **Public artefacts.** `"public": true`, `"headers": { "content-type": "text/html; charset=utf-8" }`, a string body with the page: a payment link or a receipt the prospect opens on their phone.
7. **Transactional records.** Use a resource for anything the assistant creates and later reads back (claims, appointments, orders); `POST` returns the id the assistant will quote, `GET /:id` proves it persisted.
8. **Rehearse, then reset.** `POST /api/projects/:slug/reset` before every run. `GET …/logs` is the first place to look when a tool "didn't work": it shows the exact request, the matched rule and the response.
9. **Keep it in git.** `GET …/export?include_key=1` → JSON in the account's repo; `PUT /api/projects/:slug` re-creates it anywhere.

## 7. Building a mock step by step (checklist for an agent)

1. Agree the verbs of the demo (4-8): what the assistant must *do*, not what it should know.
2. `POST /api/projects { name, slug }` → note `base_url` and `auth.key`.
3. Write the whole definition and `PUT /api/projects/<slug>` it (idempotent; re-PUT on every change). Endpoints for the verbs, resources for the nouns the assistant creates.
4. Test each verb with curl using the project key. Check `GET …/logs`. Iterate with `POST /api/render` when a template misbehaves.
5. `GET …/studiochat-tools` → create the api-tools in the Studio Chat account (section 8), rewriting descriptions to say WHEN to call each tool.
6. Run a conversation; read the logs; adjust rules. `POST …/reset` before the real demo.

## 8. Wiring Studio Chat api-tools

`GET /api/projects/:slug/studiochat-tools` returns, for every endpoint and resource, a payload in the shape the superadmin MCP's `manage_api_tools` (action `create`, argument `tool`) and `POST /projects/{id}/api-tools` accept:

```json
{
  "name": "consultar_cliente",
  "description": "…",
  "url": "https://mapis.stdcht.io/banco/clientes/{{ dni }}",
  "method": "GET",
  "headers": { "X-API-Key": "mk_…" },
  "parameters": [{ "name": "dni", "description": "The dni in the URL path" }],
  "data_expiration_hours": 0
}
```

- URL placeholders come from path params (and from `request.query.properties`, appended as `?a={{ a }}`); `body_fields` come from `request.body.properties` (typed, `required` from the schema, descriptions from the schema); for resources, `list_<name>`, `get_<name>`, `create_<name>`, `update_<name>` are generated with fields inferred from a sample record.
- Rewrite each `description` so it says when the assistant should call the tool and what it returns; `data_expiration_hours: 0` marks the data as live.
- After creating a tool, reference it from the playbook or a skill with `{{ tool(TOOL_ID) }}`; a tool no version references is not exposed to the model. Test it with `test_api_tool`, and trim big responses with `response_jmespath` if needed.
- Tools whose URL is on `stdcht.io` show `is_managed: true` in Studio Chat (derived from the URL).

## 9. Gotchas

- Everything the model fills arrives as a **string**. Compare loosely (built in), convert with `num()` when doing arithmetic, and never rely on `typeof body.x === "number"`.
- The default rule goes **last**. Saving a default that shadows later rules is a 400.
- Reserved under a project: `__reset` (and any `__…` path). Reserved slugs: `api health import projects docs static admin mapis`.
- `seq()` counters and resource data are per project and reset together. Template counters live in the same table as record ids.
- Limits: 300 endpoints and 60 resources per project, 5000 records per resource, 2000 seed records, 25 s delay, 1 MB bodies, log ring ≤ 2000.
- Deleting a project deletes all of its data; there is no undo. Export first.
- The mock does not persist anything across a `reset` except the definition (endpoints, resources, seeds) and the key.

## 10. Complete example

A `PUT /api/projects/demo-banco` with this body creates a whole demo (a customer lookup with a curated deck, a two-phase transfer with validation, a public receipt page, and a claims resource). It is executed by the worker's test suite, so it is guaranteed to be valid.

```json
{
  "project": {
    "name": "Demo Banco",
    "description": "Backend simulado de un banco para la demo comercial",
    "auth": {
      "mode": "api_key",
      "header": "X-API-Key"
    },
    "timezone": "America/Argentina/Buenos_Aires"
  },
  "endpoints": [
    {
      "method": "GET",
      "path": "/clientes/:dni",
      "name": "consultar_cliente",
      "description": "Perfil, segmento y saldo del cliente por DNI. Usar cuando el cliente se identifica.",
      "responses": [
        {
          "when": {
            "params.dni": "30123456"
          },
          "body": {
            "dni": "30123456",
            "nombre": "Martina López",
            "segmento": "premium",
            "saldo": 152300.5,
            "alta": "{{ date('-400d', 'date') }}",
            "tarjeta": "{{ mask('4509953566233704') }}"
          }
        },
        {
          "when": {
            "params.dni": {
              "matches": "^\\d{7,8}$"
            }
          },
          "seed": "{{ params.dni }}",
          "body": {
            "dni": "{{ params.dni }}",
            "nombre": "{{ name() }}",
            "segmento": "{{ pick('clasico', 'gold') }}",
            "saldo": "{{ int(1000, 90000) }}",
            "alta": "{{ date('-' + int(30, 900) + 'd', 'date') }}"
          }
        },
        {
          "status": 404,
          "body": {
            "error": "cliente_no_encontrado",
            "message": "No existe un cliente con ese DNI"
          }
        }
      ]
    },
    {
      "method": "POST",
      "path": "/transferencias/cotizar",
      "name": "cotizar_transferencia",
      "description": "Cotiza una transferencia (comisión y acreditación) sin ejecutarla.",
      "request": {
        "body": {
          "required": [
            "cbu",
            "monto"
          ],
          "properties": {
            "cbu": {
              "type": "string",
              "minLength": 22,
              "maxLength": 22,
              "description": "CBU destino, 22 dígitos"
            },
            "monto": {
              "type": "number",
              "minimum": 1,
              "description": "Monto en pesos"
            }
          }
        }
      },
      "responses": [
        {
          "when": {
            "body.monto": {
              "gt": 500000
            }
          },
          "status": 422,
          "body": {
            "error": "supera_limite",
            "message": "El límite diario es $ 500.000",
            "limite_diario": 500000,
            "alternativa": "Dividir en dos días o pedir ampliación de límite"
          }
        },
        {
          "when": {
            "body.cbu": {
              "starts_with": "999"
            }
          },
          "status": 404,
          "body": {
            "error": "cbu_inexistente",
            "message": "El CBU no corresponde a una cuenta válida"
          }
        },
        {
          "body": {
            "monto": "{{ num(body.monto) }}",
            "comision": "{{ round(num(body.monto) * 0.005, 2) }}",
            "total": "{{ round(num(body.monto) * 1.005, 2) }}",
            "acreditacion": "{{ date('+1d', 'dmy') }}",
            "banco_destino": "{{ derive_pick(body.cbu, 'Banco Nación', 'Banco Galicia', 'Santander', 'Brubank') }}"
          }
        }
      ]
    },
    {
      "method": "POST",
      "path": "/transferencias",
      "name": "ejecutar_transferencia",
      "description": "Ejecuta una transferencia. Sólo después de que el cliente confirmó la cotización.",
      "request": {
        "body": {
          "required": [
            "cbu",
            "monto"
          ],
          "properties": {
            "cbu": {
              "type": "string"
            },
            "monto": {
              "type": "number",
              "minimum": 1
            },
            "confirm": {
              "type": "boolean",
              "description": "Debe ser true; la tool de cotización lo manda en false"
            }
          }
        }
      },
      "responses": [
        {
          "when": {
            "body.confirm": true
          },
          "status": 201,
          "delay_ms": 600,
          "body": {
            "id": "TRF-{{ pad(seq('trf', 900001), 6) }}",
            "estado": "PENDIENTE",
            "monto": "{{ num(body.monto) }}",
            "fecha": "{{ now() }}",
            "acreditacion_estimada": "{{ date('+1d', 'dmy') }}",
            "comprobante_url": "{{ base_url }}/comprobantes/TRF-{{ pad(seq_current('trf'), 6) }}"
          }
        },
        {
          "status": 400,
          "body": {
            "error": "confirmacion_requerida",
            "message": "Cotizá primero y pedile confirmación explícita al cliente"
          }
        }
      ]
    },
    {
      "method": "GET",
      "path": "/comprobantes/:id",
      "name": "comprobante",
      "description": "Página pública del comprobante (se manda por WhatsApp).",
      "public": true,
      "headers": {
        "content-type": "text/html; charset=utf-8"
      },
      "body": "<!doctype html><h1>Comprobante {{ params.id }}</h1><p>Emitido {{ date('now', 'human') }}</p>"
    }
  ],
  "resources": [
    {
      "name": "reclamos",
      "description": "Reclamos de clientes",
      "seed": [
        {
          "id": "RCL-00001",
          "dni": "30123456",
          "motivo": "cargo duplicado",
          "monto": 6000,
          "estado": "ABIERTO",
          "creado": "{{ date('-2d', 'date') }}"
        },
        {
          "id": "RCL-00002",
          "dni": "30123456",
          "motivo": "tarjeta no llegó",
          "monto": 0,
          "estado": "RESUELTO",
          "creado": "{{ date('-40d', 'date') }}"
        }
      ],
      "count": 6,
      "schema": {
        "id": "RCL-{{ pad(seq('rcl', 1001), 5) }}",
        "dni": "{{ dni() }}",
        "motivo": "{{ pick('cargo duplicado', 'tarjeta no llegó', 'cobro desconocido') }}",
        "monto": "{{ int(1000, 90000) }}",
        "estado": "{{ pick('ABIERTO', 'EN_ANALISIS', 'RESUELTO') }}",
        "creado": "{{ date('-' + int(1, 30) + 'd', 'date') }}"
      }
    }
  ]
}
```

Then:

```
GET  /demo-banco/clientes/30123456          → the scripted customer
GET  /demo-banco/clientes/27888999          → a derived customer, identical on every call
POST /demo-banco/transferencias/cotizar     {"cbu":"2850590940090418135201","monto":"40.000"}   → quote with comision/total
POST /demo-banco/transferencias             {"cbu":"…","monto":"40.000"}                          → 400 confirmacion_requerida
POST /demo-banco/transferencias             {"cbu":"…","monto":"40.000","confirm":true}           → 201 TRF-900001 + comprobante URL
GET  /demo-banco/comprobantes/TRF-900001    → public HTML
GET  /demo-banco/reclamos?dni=30123456      → the customer's claims
POST /demo-banco/reclamos                   {"dni":"30123456","motivo":"cobro desconocido"}      → 201 with id, estado, creado filled in
POST /api/projects/demo-banco/reset         → back to the deck
```

Full human documentation: `https://mapis.stdcht.io/` (HTML). Source: `studiochat-workers/mapis`.
