# Emaús — especificação de integração para o app

**Base:** `https://biblia-api.virtues.cc`
**Docs interativos:** `https://biblia-api.virtues.cc/docs` · **spec:** `/openapi.yaml`
**Auth:** `X-API-Key: <chave>` (ou `Authorization: Bearer <chave>`) em tudo, exceto
`/v1/health`, `/v1/actions`, `/openapi.yaml`, `/docs`.
**Catálogo desta spec:** `catalog_version` `1.0.0`

---

## 1. A ideia em um parágrafo

O agente **não tem a Bíblia**. O acervo bíblico, as orações, os planos e o estado do usuário
estão no app. Então o agente trabalha pedindo: ele emite **actions**, e **quem executa é o
app**. Umas actions são leitura (`mode: "fetch"` — o app devolve um dado e o agente continua o
raciocínio com ele); outras são de tela (`mode: "ui"` — o app navega, abre oração, agenda
lembrete). O trabalho do dev é implementar um handler por action e ligar os dois fios: entregar
o resultado dos `fetch` e executar os `ui`.

Não existe "o agente citou um versículo que não existe": se o `fetch_passage` não voltar, ele
foi instruído a não citar nada.

---

## 2. Os dois transportes

| | `POST /v1/chat` | `POST /v1/chat/stream` |
|---|---|---|
| Formato | JSON de uma vez | SSE (`text/event-stream`) |
| Actions `ui` | sim, no campo `actions` da resposta | sim, evento `action` + repetidas no `done` |
| Actions `fetch` | **não funciona** | **sim** |
| Quando usar | telas sem conteúdo bíblico, jobs, testes | **o chat do app** |

Sem canal de volta, o `fetch` é impossível no modo sync: o agente recebe
`sem_canal_de_leitura` e responde sem citar texto. **Use SSE no chat.** O `/v1/chat` existe
para uso agentic simples (um resumo, uma classificação, um job de backend).

---

## 3. Ciclo completo (SSE)

```
app                                   emaus-api                         agente
 │  POST /v1/chat/stream                  │                                │
 ├─────────────────────────────────────►  │  sobe o turno                  │
 │  ◄── event: started                    │ ──────────────────────────────►│
 │  ◄── event: heartbeat (a cada 2s)      │                                │
 │                                        │  ◄── tool call fetch_passage ──┤
 │  ◄── event: action (mode=fetch) ───────┤     (turno PARA aqui)          │
 │                                        │                                │
 │  POST /v1/chat/continue                │                                │
 ├─────────────────────────────────────►  │ ── resultado ─────────────────►│
 │                                        │                                │
 │  ◄── event: action (mode=ui) ──────────┤  ◄── tool call open_prayer ────┤
 │  ◄── event: done {reply, actions[]} ───┤  ◄── texto final ──────────────┤
```

Regras que o app precisa respeitar:

1. **`mode: "fetch"` bloqueia o turno.** Responda com `/v1/chat/continue` dentro de
   `expires_in_ms` (60 s). Se estourar, o agente recebe `app_nao_respondeu` e segue sem o dado
   — a resposta fica pior, mas o turno não trava.
2. **`mode: "ui"` não se responde.** Guarde e execute quando chegar o `done`. Executar antes é
   errado: o turno ainda pode falhar.
3. **Não feche o SSE enquanto houver `fetch` pendente.** Fechar cancela o turno.
4. **Uma conversa por `session_id`.** Chamadas concorrentes na mesma sessão são enfileiradas
   pela API; não paralelize dentro de uma conversa.

---

## 4. Requisição

```jsonc
POST /v1/chat/stream
X-API-Key: <chave>
Content-Type: application/json

{
  "message": "tô ansioso essa semana, me ajuda",

  // identifica a conversa; a memória do agente é por sessão.
  // omitido = sessão nova a cada mensagem (o agente não lembra de nada).
  "session_id": "6f1c7b0e-2f2a-4a8a-9a1f-1a2b3c4d5e6f",

  // o que ESTA versão do app sabe executar. Omitido = catálogo inteiro.
  // [] = chat puro, nenhuma action.
  "enabled_actions": ["fetch_passage", "search_scripture", "open_passage", "list_prayers", "get_prayer", "open_prayer"],

  // estado da tela. Vira prefixo do turno; o agente usa de verdade.
  "client_context": {
    "tela": "leitor",
    "referencia_aberta": "Romanos 8:28",
    "versao_preferida": "NVI",
    "plano_ativo": "plan_paz_21",
    "dia_do_plano": 4
  }
}
```

**`enabled_actions` é a trava de versão.** Mande sempre a lista real da build instalada. Action
fora dela é recusada pela API antes de virar promessa na resposta — o agente recebe
`action_nao_suportada_nesta_versao` e resolve na conversa. Nome que não existe no catálogo volta
em `unknown_actions` no `started` (bom sinal de que o app está mais novo que a API, ou o
contrário).

**`client_context` vale muito.** Com `referencia_aberta` preenchida, "explica esse versículo"
funciona sem o usuário repetir a referência. Mande sempre `tela` e `versao_preferida`.

---

## 5. Eventos do SSE

| evento | payload |
|---|---|
| `started` | `{session_id, turn_id, catalog_version, unknown_actions[]}` |
| `heartbeat` | `{elapsed_ms}` — a cada 2 s; use para spinner e para detectar conexão morta |
| `action` | `{action_id, name, mode, args, expires_in_ms?}` |
| `done` | `{reply, session_id, actions[], used_tools[], latency_ms, catalog_version}` |
| `error` | `{error, detail, session_id, latency_ms}` |

`done.reply` é o texto para mostrar. `done.actions` repete as actions `ui` do turno (com
`action_id`) — é a lista canônica para executar. `done.used_tools` traz tudo que o agente
chamou, na ordem, incluindo os `fetch`: sirva para log e telemetria, não para UI.

### Devolvendo um `fetch`

```jsonc
POST /v1/chat/continue
X-API-Key: <chave>

{
  "session_id": "6f1c7b0e-...",
  "action_id": "99e7f41f-...",     // veio no evento action
  "result": { /* o dado; formato no §6 */ }
}
```

Não conseguiu executar? Mande `error` em vez de `result`:

```jsonc
{ "session_id": "...", "action_id": "...", "error": "referência fora do acervo instalado" }
```

O agente lê esse motivo e se ajusta. **O que ele não pode é ficar sem resposta** — aí só resta
esperar os 60 s.

---

## 6. Catálogo de actions (v1.0.0)

Fonte da verdade em runtime: `GET /v1/actions` (traz o JSON Schema de cada uma). Abaixo, o que
o app precisa implementar.

### 6.1 Leitura — `mode: "fetch"`

#### `fetch_passage`
Buscar o texto de uma passagem. **A action mais importante**: sem ela o agente não cita nada.

```jsonc
// args
{ "reference": "Filipenses 4:6-7", "version": "NVI" }   // version opcional → use a preferida do usuário

// result esperado
{
  "reference": "Filipenses 4:6-7",
  "version": "NVI",
  "verses": [
    { "number": 6, "text": "Não andem ansiosos por coisa alguma, mas em tudo..." },
    { "number": 7, "text": "E a paz de Deus, que excede todo o entendimento..." }
  ]
}
```

O `reference` chega normalizado em português (`João 3:16`, `Salmos 23`, `1 Coríntios 13`,
faixas com hífen, capítulo inteiro sem `:`). **Implemente um parser tolerante mesmo assim** —
aceite abreviação (`Fp`, `Sl`, `1Co`), com e sem acento, `Salmo`/`Salmos`. Referência que não
resolve: devolva `error`, não um objeto vazio.

Capítulo inteiro pode ser grande; devolva completo, o agente corta o que citar.

#### `search_scripture`
Busca por palavra ou tema.

```jsonc
// args
{ "query": "ansiedade", "scope": "NT", "version": "NVI", "limit": 10 }

// result
{ "hits": [ { "reference": "Filipenses 4:6-7", "text": "...", "score": 0.94 } ] }
```

`scope` é livre: `"AT"`, `"NT"`, nome de livro, faixa. Ignore o que não souber tratar. Sem
busca semântica, um LIKE/FTS no texto já resolve a maior parte. `score` é opcional.

#### `list_prayers` / `get_prayer`

```jsonc
// list_prayers args: { "category": "ansiedade", "query": "...", "limit": 10 }
{ "prayers": [ { "prayer_id": "pr_ansiedade_01", "title": "...", "category": "ansiedade", "excerpt": "..." } ] }

// get_prayer args: { "prayer_id": "pr_ansiedade_01" }
{ "prayer_id": "pr_ansiedade_01", "title": "...", "category": "ansiedade", "text": "...", "references": ["Fp 4:6-7"] }
```

`prayer_id` **é opaco e vem de vocês**. O agente foi instruído a nunca inventar um: ele sempre
lista antes de abrir.

#### `list_reading_plans`

```jsonc
// args: { "query": "...", "limit": 10 }
{ "plans": [ { "plan_id": "plan_paz_21", "title": "21 dias de paz", "days": 21, "theme": "ansiedade" } ] }
```

#### `user_state`
O recorte do usuário. **Não mande dado sensível que não sirva para a conversa.**

```jsonc
// args: { "include": ["plan", "favorites", "highlights", "history", "preferences"] }
{
  "preferences": { "version": "NVI", "idioma": "pt-BR" },
  "plan": { "plan_id": "plan_paz_21", "title": "21 dias de paz", "dia_atual": 4, "dias_perdidos": 2 },
  "favorites": [ { "kind": "passage", "reference": "Sl 23" } ],
  "highlights": [ { "reference": "Rm 8:28", "color": "amarelo", "note": "..." } ],
  "history": ["Sl 23", "Jo 1"]
}
```

Devolva só os recortes pedidos em `include`. Campo ausente = "não tenho", e o agente lida.

### 6.2 Tela — `mode: "ui"`

Não devolvem nada. Chegam no `done.actions` e o app executa. Todas são idempotentes do ponto de
vista do agente: se falhar, não avise o agente, trate no app.

| action | args | o que o app faz |
|---|---|---|
| `navigate` | `{screen, params?}` | vai para a tela. `screen`: `home`, `bible`, `prayers`, `plans`, `favorites`, `settings` (ajuste a lista à sua navegação e reflita em `enabled_actions`) |
| `open_passage` | `{reference, version?, highlight?[]}` | abre o leitor na referência, destacando o que vier em `highlight` |
| `open_prayer` | `{prayer_id}` | abre a oração |
| `play_audio` | `{kind: "passage"\|"prayer", reference?, prayer_id?}` | toca o áudio, se existir para aquele conteúdo |
| `start_plan` | `{plan_id, start_date?}` | inicia o plano. **Só chega depois do usuário concordar** |
| `save_highlight` | `{reference, color?, note?}` | marca/anota o versículo |
| `add_favorite` | `{kind, reference?, prayer_id?}` | favorita |
| `set_reminder` | `{kind, time, days?[], label?}` | agenda lembrete local. `kind`: `reading`, `prayer`, `devotional`; `time`: `HH:MM` local |
| `share` | `{kind, reference?, prayer_id?, text?}` | abre o compartilhamento nativo |

O agente está instruído a: no máximo **2 actions de tela por turno**, sempre anunciadas no
texto, e nunca `start_plan` / `set_reminder` / `save_highlight` / `share` sem o usuário ter
pedido ou concordado. Ainda assim, **o app é a última trava**: para ação que mexe na conta,
confirme na interface se sua UX pedir.

---

## 7. Como o app implementa (pseudocódigo)

```ts
const registry = {
  // fetch → devolve dado
  fetch_passage:      (a) => biblia.getPassage(a.reference, a.version ?? prefs.version),
  search_scripture:   (a) => biblia.search(a.query, a.scope, a.limit ?? 10),
  list_prayers:       (a) => oracoes.list(a.category, a.query, a.limit ?? 10),
  get_prayer:         (a) => oracoes.get(a.prayer_id),
  list_reading_plans: (a) => planos.list(a.query, a.limit ?? 10),
  user_state:         (a) => estado.snapshot(a.include ?? ['preferences', 'plan']),

  // ui → executa, não devolve
  navigate:       (a) => nav.go(a.screen, a.params),
  open_passage:   (a) => nav.leitor(a.reference, a.version, a.highlight),
  open_prayer:    (a) => nav.oracao(a.prayer_id),
  play_audio:     (a) => player.play(a),
  start_plan:     (a) => planos.iniciar(a.plan_id, a.start_date),
  save_highlight: (a) => marcacoes.salvar(a),
  add_favorite:   (a) => favoritos.add(a),
  set_reminder:   (a) => lembretes.agendar(a),
  share:          (a) => share.abrir(a),
};

const ENABLED = Object.keys(registry);   // exatamente o que esta build implementa

async function conversar(texto, sessionId) {
  const uiPendentes = [];
  const stream = await sse('/v1/chat/stream', {
    message: texto,
    session_id: sessionId,
    enabled_actions: ENABLED,
    client_context: contextoDaTela(),
  });

  for await (const ev of stream) {
    switch (ev.event) {
      case 'heartbeat':
        ui.spinner(ev.data.elapsed_ms);
        break;

      case 'action':
        if (ev.data.mode === 'fetch') {
          // NÃO bloqueie a leitura do stream esperando isto
          void responderFetch(sessionId, ev.data);
        } else {
          uiPendentes.push(ev.data);       // executa só no done
        }
        break;

      case 'done':
        ui.mostrar(ev.data.reply);
        for (const a of ev.data.actions) registry[a.name]?.(a.args);
        return;

      case 'error':
        ui.erro('Não consegui responder agora.');   // detalhe vai pro log, não pra tela
        return;
    }
  }
}

async function responderFetch(sessionId, action) {
  try {
    const result = await registry[action.name](action.args);
    await post('/v1/chat/continue', { session_id: sessionId, action_id: action.action_id, result });
  } catch (e) {
    await post('/v1/chat/continue', { session_id: sessionId, action_id: action.action_id, error: String(e).slice(0, 300) });
  }
}
```

**Armadilha:** executar o `fetch` de forma síncrona dentro do loop de leitura do SSE trava o
stream e você perde os eventos seguintes. Dispare e continue lendo.

---

## 8. Erros e degradação

| situação | o que a API/agente faz | o que o app faz |
|---|---|---|
| `fetch` sem resposta em 60 s | agente recebe `app_nao_respondeu`, responde sem citar | nada; investigue no log |
| app devolve `error` | agente lê o motivo e se ajusta | nada |
| action fora de `enabled_actions` | `action_nao_suportada_nesta_versao` | nada |
| `POST /v1/chat` com `fetch` | `sem_canal_de_leitura` | migrar para SSE |
| `429 rate_limited` | 60 chamadas/min por chave | backoff; não repita em loop |
| `502 agent_exited_*` | falha do turno | mensagem curta e limpa na tela, detalhe no log |
| conexão SSE cai | turno é cancelado no servidor | reenvie a mensagem com o **mesmo** `session_id` |

Mensagem de falha para o usuário: curta e humana. Detalhe técnico vai para o log.

---

## 9. Versionamento

- `catalog_version` vem no `started`, no `done` e em `/v1/actions`. Guarde no log de cada turno.
- Action **nova** entra no catálogo sem quebrar app antigo: quem não manda o nome em
  `enabled_actions` nunca a recebe.
- Mudança **incompatível** de argumentos vira **action nova** (`fetch_passage_v2`), não edição da
  antiga.
- O app deve tolerar campo desconhecido em `args` (ignore) e action desconhecida no `done`
  (ignore + log).

---

## 10. Segurança e privacidade

- A chave de API é **de servidor**. Não embarque no app: o cliente fala com o backend de vocês,
  e o backend fala com a Emaús. Chave em app publicado é chave vazada.
- `client_context` e `user_state` viajam para o modelo. Mande o mínimo: nada de e-mail, telefone,
  documento, localização precisa ou identificador de conta.
- `session_id` deve ser opaco e não derivado de dado pessoal.
- O agente tem regra de encaminhamento para risco de vida (CVV 188, SAMU 192, 180, 190). Se o
  app tiver tela própria de ajuda, avise que dá para incluir.

---

## 11. Testando sem o app pronto

Há um cliente de teste no servidor, em `~/emaus-api/tools/mockapp.mjs`: ele abre o SSE, responde
todo `fetch` com dado falso e imprime os eventos. Serve de referência de implementação e de
teste de fumaça:

```bash
ssh kaycke@148.72.158.181
set -a; . ~/emaus-api/env; set +a
node ~/emaus-api/tools/mockapp.mjs "tô ansioso essa semana, me ajuda"
```

Contra a produção, de qualquer máquina:

```bash
curl -N -X POST https://biblia-api.virtues.cc/v1/chat/stream \
  -H "X-API-Key: $EMAUS_API_KEY" -H 'Content-Type: application/json' \
  -d '{"message":"me dá um versículo sobre esperança","enabled_actions":["fetch_passage"]}'
```

Checklist antes de fechar a integração:

- [ ] `enabled_actions` reflete exatamente o que a build implementa
- [ ] `client_context` manda `tela` e `versao_preferida` em toda mensagem
- [ ] `fetch` responde em menos de 3 s no caminho feliz
- [ ] `fetch` que falha manda `error`, nunca fica sem resposta
- [ ] actions `ui` só executam no `done`
- [ ] reconexão reusa o mesmo `session_id`
- [ ] chave de API só no backend

---

## 12. Onde as coisas rodam

| peça | onde |
|---|---|
| Agente `emaus` (openclaw) | `148.72.158.181`, gateway em `127.0.0.1:18790` |
| `emaus-api` | mesmo host, `127.0.0.1:4280`, systemd `emaus-api` |
| Ponte MCP | `~/emaus-mcp/server.mjs`, subida pelo gateway |
| Catálogo de actions | `~/emaus-api/actions.json` (SIGHUP no `emaus-api` recarrega) |
| Prompt do agente | `~/.openclaw/workspace/agents/emaus/AGENTS.md` + `skills/` |
| Túnel para o proxy | systemd `emaus-tunnel` → `virtues.cc:127.0.0.1:4281` |
| Vhost público | `biblia-api.virtues.cc` no `virtues.cc` (Apache + Let's Encrypt) |
| Logs | `~/.openclaw/logs/emaus-api.jsonl` (um evento por turno e por action) |
