# Mantener al día lo que el agente sabe

Guía para empujar documentos a una colección de conocimiento desde un sistema propio, con una
credencial de organización. Sirve para un flujo de n8n, para una GitHub Action, o para cualquier
cosa que sepa hacer un `PUT`.

---

## Qué resuelve esto

Un agente contesta con lo que tiene en sus colecciones de conocimiento. Hasta ahora esas colecciones
se llenaban de tres maneras y ninguna se sostenía sola: subiendo archivos a mano por la pantalla —y
acordándose de volver a subirlos cada vez que cambian—, conectando Notion, o escribiendo un script
propio con el usuario y la contraseña de una persona en variables de entorno.

El resultado es siempre el mismo: la documentación se sube una vez y después envejece. Y un agente
que cita documentación vencida es peor que uno que no cita nada, porque suena igual de seguro.

Con esto, el sistema que ya tiene los archivos los empuja cuando cambian. Nadie tiene que acordarse
de nada.

---

## 1. La llamada

```
PUT https://<host>/v1/external/knowledge/documents
Authorization: Bearer lel_<prefijo>_<secreto>
Content-Type: application/json
```

```json
{
  "collectionId": "3f2b1c4d-...",
  "filename": "docs/precios.md",
  "content": "# Precios\n\nEl plan chico sale 19.990 + IVA.\n"
}
```

| Campo | Qué es |
|---|---|
| `collectionId` | La colección donde vive el documento. Tiene que ser de la organización de la credencial. |
| `filename` | **La ruta relativa del archivo**, no el nombre a secas. Es la identidad del documento. |
| `content` | El contenido, en markdown. |

La respuesta:

```json
{
  "id": "9a8b7c6d-...",
  "collectionId": "3f2b1c4d-...",
  "filename": "docs/precios.md",
  "status": "processing"
}
```

`status: "processing"` y no `"ready"` a propósito: el documento se trocea y se vectoriza después, en
segundo plano. Decir "listo" en la respuesta sería prometer que el agente ya lo sabe cuando todavía
no lo leyó. En condiciones normales tarda segundos.

---

## 2. Es `PUT` porque reemplazar es lo normal

**Empujar dos veces el mismo `filename` deja un documento, no dos.** El segundo pisa al primero y
sus trozos se rehacen con el contenido nuevo.

Esa es la parte importante de todo esto. Si cada llamada creara un documento nuevo, un flujo que
corre en cada push dejaría veinte copias del mismo archivo, y el agente citaría la que le tocara —
con una de ellas vencida y contestada con la misma seguridad que la buena.

Por eso el `filename` es **la ruta relativa y no el nombre**. Cinco `README.md` en cinco carpetas
son cinco documentos distintos; mandar sólo `README.md` los haría pisarse entre ellos. Mandá
`docs/README.md`, `api/README.md`, etc.

**Cambiar el nombre de un archivo crea un documento nuevo y deja el viejo.** Para nosotros el nombre
*es* la identidad, así que un renombre no lo podemos distinguir de un archivo nuevo. Si renombrás
mucho, conviene borrar por la pantalla lo que quedó colgando.

**Lo que gestiona una fuente conectada no se toca.** Si la colección tiene Notion conectado y una
página se llama igual que tu archivo, tu `PUT` crea el suyo y deja la página de Notion en paz. Cada
uno tiene su dueño.

---

## 3. El ritmo: 200 pedidos por minuto

Es el techo por dirección IP, compartido con el resto de la API externa (el de la credencial es de
3.000). El pedido 201 dentro del mismo minuto recibe `429`.

**No tenés que adivinar cuánto esperar.** Cada respuesta trae en qué anda tu balde, y el `429` trae
además cuántos segundos faltan:

| Encabezado | Qué dice |
|---|---|
| `RateLimit-Limit` | El techo que se está contando. |
| `RateLimit-Remaining` | Cuántos pedidos te quedan en esta ventana. |
| `RateLimit-Reset` | En cuántos segundos se libera un cupo. |
| `Retry-After` | Sólo en el `429`: cuántos segundos esperar antes de reintentar. |

**La regla práctica: empujá sólo lo que cambió.** Además de esquivar el límite, es lo barato — un
documento que se vuelve a empujar se re-vectoriza aunque no haya cambiado ni una palabra.

Para el relleno inicial de un repositorio grande, hay que espaciar. Quinientos archivos a 200 por
minuto son unos 3 minutos, y se corre una sola vez.

---

## 4. Una GitHub Action que empuja lo que cambió

```yaml
name: sincronizar conocimiento
on:
  push:
    branches: [main]
    paths: ['docs/**/*.md']

jobs:
  empujar:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          # Hacen falta dos commits para poder preguntar qué cambió.
          fetch-depth: 2

      - name: empujar los .md que cambiaron
        env:
          LELEMON_KEY: ${{ secrets.LELEMON_API_KEY }}
          COLECCION: ${{ vars.LELEMON_COLLECTION_ID }}
          HOST: ${{ vars.LELEMON_HOST }}
        run: |
          set -euo pipefail

          # Sólo lo agregado o modificado. Para los borrados hay
          # `DELETE /v1/external/knowledge/documents/{id}` — ver la sección 8.
          git diff --name-only --diff-filter=AM HEAD~1 HEAD -- 'docs/**/*.md' > cambiados.txt

          while IFS= read -r archivo; do
            [ -f "$archivo" ] || continue

            # `--arg` y no interpolación: el contenido tiene comillas y saltos
            # de línea, y armar el JSON a mano lo rompe en el primer archivo
            # que traiga un `"`.
            jq -n \
              --arg c "$COLECCION" \
              --arg f "$archivo" \
              --rawfile t "$archivo" \
              '{collectionId:$c, filename:$f, content:$t}' > cuerpo.json

            codigo=$(curl -sS -o respuesta.json -w '%{http_code}' \
              -X PUT "$HOST/v1/external/knowledge/documents" \
              -H "Authorization: Bearer $LELEMON_KEY" \
              -H 'Content-Type: application/json' \
              --data @cuerpo.json)

            if [ "$codigo" != "200" ]; then
              echo "::error::$archivo devolvio $codigo"
              cat respuesta.json
              exit 1
            fi
            echo "ok $archivo"

            # Medio segundo entre archivos: el techo son 200 por minuto.
            sleep 0.5
          done < cambiados.txt
```

Dos cosas de esa receta que valen para cualquier implementación:

- **Armá el JSON con una herramienta, no concatenando.** El contenido de un `.md` trae comillas,
  saltos de línea y backslashes. El primer archivo con una comilla rompe un JSON armado a mano.
- **Mirá el código HTTP, no el código de salida de `curl`.** `curl` sale 0 con un `403` o un `429`:
  el pedido salió bien, el que dijo que no fue el servidor. Sin el `-w '%{http_code}'` el flujo
  queda verde sin haber subido nada.

---

## 5. Un flujo de n8n

La misma idea con nodos, para el caso de que los archivos no vivan en un repositorio.

1. **Disparador** — lo que corresponda: un cron diario, un webhook, un cambio en un Drive.
2. **Un nodo por archivo** — si vienen varios, `Split In Batches` con tamaño 1 y una espera de
   medio segundo entre lotes. En paralelo funciona igual de bien del lado nuestro, pero se choca con el
   límite de 200 por minuto mucho más rápido.
3. **HTTP Request**:
   - Método: `PUT`
   - URL: `https://<host>/v1/external/knowledge/documents`
   - Autenticación: credencial de tipo *Header Auth*, nombre `Authorization`, valor
     `Bearer lel_...`. **Como credencial de n8n, no escrita adentro del nodo** — adentro del nodo
     queda en claro en cada exportación del flujo.
   - Cuerpo: JSON, con `collectionId`, `filename` y `content`.
4. **Rama de error** — que un `4xx` avise a alguien. Un flujo que falla en silencio deja la
   documentación vencida sin que nadie se entere, que es exactamente el problema que vinimos a
   resolver.

---

## 6. Qué puede salir mal

| Código | Qué pasó | Qué hacer |
|---|---|---|
| `400` | Falta un campo, o el contenido está vacío. | El cuerpo trae `errors`, con el campo que falló y **la forma** de lo que mandaste (`"string(13)"`), nunca su valor. `message` sigue diciendo `"Validation failed"`: el detalle está al lado. |
| `401` | La credencial no existe, se revocó, o no viaja en `Authorization: Bearer`. | Si venía funcionando, es que se revocó o se rotó. La respuesta trae `WWW-Authenticate: Bearer`. |
| `403` | A la credencial le falta `knowledge:write`. | El mensaje nombra el permiso. Se agrega desde la pantalla de desarrolladores. |
| `404` | La colección no existe **o no es de tu organización**. | Las dos cosas se responden igual a propósito: distinguirlas diría si el id de otro existe. |
| `413` | El documento pasa los 15 MB. | Partilo. Un documento de ese tamaño además contesta mal: el buscador trae trozos, y mientras más grande el archivo, menos preciso el trozo. |
| `429` | Pasaron un techo por minuto: 200 pedidos por dirección IP o 3.000 por credencial. | Esperá lo que diga `Retry-After` y reintentá. Ver la sección 3. |
| `500` | No pudimos encolar la indexación. | El documento queda marcado como fallido, no a medio hacer. Reintentá; si sigue, escribinos. |

Sobre el `500`: si la indexación no se pudo encolar, el documento queda en estado `failed` y se ve
así en la pantalla. Es a propósito. Dejarlo en `processing` sería peor: se vería igual que uno recién
subido y nadie lo volvería a mirar nunca.

---

## 7. Si el agente no cita tus documentos

Empujar el documento no alcanza para que el agente lo use. Tres cosas tienen que darse, y las
primeras dos son de una sola vez:

**La colección tiene que estar asignada al agente.** Un documento en una colección que ningún agente
tiene asignada está indexado y no lo alcanza nadie. Se hace desde la pantalla, o con
`PUT /v1/external/agents/{agentId}/knowledge/{collectionId}` — ver la sección 8.

**La búsqueda de documentos ya viene incluida**, no hay que prenderla: un agente con conocimiento
asignado puede leerlo. Antes eran dos permisos separados y la gente hacía uno y daba por hecho el
otro.

**Y el agente tiene que querer buscar.** Es lo que sorprende: el modelo decide si consultar la
documentación, y si la pregunta le queda lejos de lo que él cree que hace, contesta de su perfil sin
buscar. Nos pasó probando esto — un agente de ventas al que le preguntamos por una garantía de
equipos contestó "nosotros no vendemos equipos" sin abrir un solo documento, y tenía razón.

Si te pasa: revisá que lo que subiste sea del dominio del agente, y que sus instrucciones le digan
que consulte la documentación antes de responder. Para comprobar rápido si buscó o no, preguntale
algo cuyo dato **sólo** pueda salir de tu documento.

---

## 8. Armar la colección desde cero, y borrar

Hasta acá todo asume que la colección ya existe y que alguien se la asignó al agente. Esos dos pasos
también se hacen por API, así que un flujo puede aprovisionar desde cero sin que nadie entre al
panel.

### Crear la colección

```
POST https://<host>/v1/external/knowledge/collections
Authorization: Bearer lel_<prefijo>_<secreto>
```

```json
{ "slug": "guias-precios", "name": "Guías de precios" }
```

**El `slug` lo elegís vos, y es la llave.** Llamar dos veces con el mismo slug devuelve **la misma
colección**, no una copia: `201` la primera vez, `200` las siguientes. Eso es lo que hace que un
flujo se pueda reintentar sin dejar `guias-precios` y `guias-precios-2`, las dos medio llenas, con el
agente leyendo una mientras el flujo empuja documentos a la otra.

Por eso el slug no se deriva del nombre: si lo hiciéramos, corregir una tilde en el nombre crearía
una colección nueva y vacía.

### Asignársela al agente

```
PUT https://<host>/v1/external/agents/{agentId}/knowledge/{collectionId}
```

Sin cuerpo. Contesta `204`. Asignar la misma dos veces no la duplica ni falla.

Va **de a una colección**, y no "acá está la lista completa", a propósito: con la lista, para agregar
una tendrías que leer las que hay, sumar la tuya y mandar todo — y entre el leer y el mandar, lo que
alguien haya asignado desde la pantalla desaparece sin que nadie se entere.

Para quitarla, el mismo camino con `DELETE`. La colección no se borra: deja de estar asignada a ese
agente. Quitar algo que no estaba tampoco es un error.

### Ver lo que hay

```
GET /v1/external/knowledge/collections?limit=50&offset=0
GET /v1/external/knowledge/collections/{collectionId}/documents?limit=50&offset=0
```

Las dos piden **`knowledge:read`**, que es un permiso distinto del de escribir. Contestan así:

```json
{ "data": [ ... ], "limit": 50, "offset": 0, "hasMore": true }
```

`hasMore` es lo único que dice cuándo parar; el offset siguiente es `offset + data.length`. El
`limit` va hasta 500, y pedir más da `400` en vez de recortarse en silencio — recortar convertiría
"pediste mal" en un número plausible.

### Borrar un documento

```
DELETE /v1/external/knowledge/documents/{documentId}
```

Contesta `204`. Borra sus trozos y lo saca de circulación: el agente deja de poder citarlo. Borrarlo
dos veces da `404` la segunda, que es información honesta —ya no está— y no rompe un reintento.

---

## 9. Qué entra es responsabilidad tuya

**Un campo que no está en la tabla se descarta en silencio.** No da error. Si mandás
`organizationId` no pasa nada: la organización sale de la credencial y de ningún otro lado. Vale la
pena saberlo porque un campo mal escrito —`fileName` en vez de `filename`— se va a descartar igual, y
el error que vas a ver es el del campo que falta.

**La API guarda lo que le mandes, tal cual.** No inspeccionamos el contenido, no lo filtramos y no
tachamos nada.

Eso tiene una consecuencia concreta que conviene tener presente: lo que empujes se trocea, se indexa
y **el agente lo puede citar textualmente** a quien esté conversando con él. La regla, en una línea:

> No empujes nada que no quieras que el agente diga en voz alta.

Si tus archivos tienen datos personales —RUT, correos, teléfonos, montos de un cliente—, la decisión
de ponerlos ahí es tuya y el filtro va de tu lado, antes del `PUT`. Nuestro script interno de
ingesta, por ejemplo, tacha los RUT y excluye carpetas por una lista antes de subir nada; eso es una
decisión de ese script, no algo que la API haga por vos.

Lo que sí hacemos de nuestro lado: el contenido queda dentro de tu organización y ninguna otra lo
alcanza. La organización sale de la credencial y de ningún otro lado — nunca la leemos del cuerpo ni
de la URL.

---

## 10. La credencial

Se emite desde **Desarrolladores** en el panel. Los permisos de conocimiento son dos:

- **`knowledge:write`** — empujar, borrar, crear colecciones y asignarlas. Es la fuente de las
  respuestas del agente.
- **`knowledge:read`** — sólo ver qué colecciones y documentos hay.

Dale el más chico que sirva: un flujo que sincroniza documentación necesita el primero; uno que
audita, el segundo.

- **Se muestra una sola vez.** De nuestro lado guardamos sólo un hash. Si se pierde, se emite otra.
- **`knowledge:write` es el permiso más peligroso que hay**: quien lo tiene cambia la fuente de las
  respuestas del agente. Una credencial para sincronizar documentación no necesita poder conversar, y
  una que conversa no necesita poder reescribir lo que el agente sabe.
- **Guardala como secreto**, no adentro del código ni de un nodo exportable.
- **Se puede revocar en cualquier momento**, y revocar es definitivo.
