Metadata-Version: 2.4
Name: ando-ai
Version: 0.4.0
Summary: Official Python client SDK and CLI for the Ando Platform API. Requires an active Ando account and API key.
Author: Ando S.r.l.
License-Expression: LicenseRef-Proprietary
Project-URL: Homepage, https://ando-ai.com
Project-URL: Repository, https://github.com/AndreaBovinelli/ando-agent
Project-URL: Issues, https://github.com/AndreaBovinelli/ando-agent/issues
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.24
Dynamic: license-file

# Ando Platform — Python SDK

Client Python **sincrono** per la Ando Platform API v1
(`https://api.ando-ai.com/platform/v1`). Unica dipendenza: `httpx>=0.24`.

Endpoint predefinito: `https://api.ando-ai.com/platform/v1`.

## Requisiti di accesso

Questo pacchetto contiene solo il client SDK: non include né rende
autonomamente disponibile il servizio Ando Platform. Per usarlo servono un
account Ando attivo (o un accesso di valutazione), i relativi diritti di
licenza e una API key emessa da Ando. Per richiedere l'attivazione, contatta
Ando su [ando-ai.com](https://ando-ai.com).

## Installazione

Requisito: Python 3.9+.

```bash
pip install ando-ai
```

## Uso

```python
from ando_ai import AndoPlatform, ApiError, JobFailed

with AndoPlatform("ando_sk_live_...") as ando:
    accepted = ando.upload_document("contratto.pdf")
    ando.wait_for_job(accepted["job_id"])

    result = ando.answer("Qual è la durata del contratto?")
    print(result["answer"], result["citations"])

    ando.delete_document(accepted["document_id"])
```

Il client è un context manager; in alternativa chiama `ando.close()` a fine
lavoro.

## Cosa puoi costruire — i tre livelli

Lo stesso stack, esposto a tre profondità crescenti. Non sono alternative:
sono quanto lavoro fa il server al posto tuo.

### 1. `query()` — recuperi i chunk, generi tu

Per chi ha già il proprio LLM/prompt e vuole solo il retrieval (con
citazioni e score).

```python
hits = ando.query("penali per ritardata consegna", k=8)
for hit in hits["results"]:
    print(hit["score"], hit["section"], hit["text"][:120])
    # hit["document_id"] / hit["chunk_id"] = la citazione da mostrare
```

### 2. `answer()` — risposta sintetizzata e ancorata

Il server recupera **e** sintetizza, citando i chunk usati. La risposta
rispecchia la lingua della domanda (non si forza nulla). Se il retrieval
non trova nulla: `answer` è `None` e `no_results` è `True`.

```python
result = ando.answer("Qual è la durata del contratto?", verify=True)
print(result["answer"])
print(result["citations"])            # [{document_id, chunk_id}, ...]
print(result["verification"])         # {verified, score, notes} | None
```

### 3. `agentic_answer()` — l'agente completo, anche sui dati strutturati (PREVIEW)

Questa è la differenza tra «ti do dei chunk» e «ho interrogato il tuo
gestionale». Gira la stessa macchina della chat prodotto: escalation TRAMA
T1→T2→T3 e, sulle **sources strutturate pubblicate** del project,
text-to-SQL — quindi una domanda la cui risposta sta in una tabella viene
risposta *dalla tabella*, non approssimata dai documenti.

**Read-only per costruzione**: nessuna scrittura, nessuna azione, nessuna
persistenza. `steps[]` mostra cosa ha fatto l'agente (tier di retrieval,
SQL eseguito con credenziali redatte, specialist scelto).

```python
result = ando.agentic_answer(
    "Quali clienti hanno esposizione più critica e per quale importo?",
    k=25,
    with_diagnostics=True,       # tier, timing, plan cache
)
print(result["answer"])
print(result["sources_used"])    # es. ["database", "fs"]
for step in result["steps"]:
    print(step["type"], step)    # tipi sconosciuti = additivi, non ramificarci sopra
```

Una chiamata agentica è multi-step: il timeout di default è
`AGENTIC_TIMEOUT` (120s), non i 30s del client. Si sovrascrive per
chiamata con `timeout=`.

> **PREVIEW**: `POST /agentic/answer` non è ancora GA — request e response
> possono cambiare. Vedi `docs/PLATFORM_API_CONTRACT.md`.

### Conversazioni — memoria tra turni agentici

Un turno agentico è stateless di default. Apri un thread con
`create_conversation()` e passa il suo id a `agentic_answer()`: i follow-up
si risolvono contro la storia del thread («e il secondo?» funziona) e ogni
scambio viene persistito per il turno successivo. Scope `query:read`.

```python
conv = ando.create_conversation(title="Analisi esposizione")
ando.agentic_answer("Chi sono i primi 3 clienti per esposizione?",
                    conversation_id=conv["conversation_id"])
ando.agentic_answer("E il secondo?",                       # follow-up risolto
                    conversation_id=conv["conversation_id"])

ando.list_conversations()                        # thread della chiave, cursor-paginati
ando.get_conversation_turns(conv["conversation_id"])   # scambi, dal più vecchio
ando.delete_conversation(conv["conversation_id"])      # irreversibile
```

### Restringere il campo: `filters`, `sources`, `partition`

Valgono su `query()` e `answer()`. Ogni filtro può solo **restringere**: la
resource policy della chiave è sempre intersecata sopra e vince.

```python
# scorciatoie da keyword (le due dimensioni più usate)
ando.answer("fatture scadute", sources=["fs"], partition="cliente-42")

# oggetto completo, con condizioni sui metadata di ingest
ando.query(
    "resi 2025",
    filters={
        "sources": ["fs", "drive"],
        "partition": "cliente-42",
        "metadata": {
            "op": "and",
            "conditions": [
                {"key": "doc_type", "op": "eq", "value": "invoice"},
                {"key": "year", "op": "gte", "value": 2025},
            ],
        },
    },
)
```

`partition` e `metadata` sono esattamente quelli impostati all'ingest — il
cerchio si chiude sullo stesso client:

```python
ando.upload_document(
    "fattura-2025-11.pdf",
    metadata={"doc_type": "invoice", "year": 2025},   # oggetto PIATTO, max 32 chiavi
    partition="cliente-42",                            # A-Za-z0-9._:- , max 128 char
)
```

Passare la stessa dimensione due volte (keyword **e** dentro `filters`) è
un `ValueError` lato client, prima di qualunque HTTP: sceglierne una in
silenzio cambierebbe quali documenti vengono cercati. I nomi di source
sconosciuti **non** vengono rifiutati dal SDK: i valori enum sono additivi
per contratto, decide il server.

## Generazione documenti — `generate()`

Dai tuoi dati a un file `docx`/`pptx`/`xlsx`/`pdf`/`md`. Il contesto arriva da
una `query` (retrieval TRAMA, resource policy rispettata) e/o da
`document_ids` espliciti; almeno uno dei due è obbligatorio (altrimenti
`ValueError` prima di qualunque HTTP). È **sempre asincrono**: `202` con un
`generation_id` da pollare, poi si scarica il file. Il contenuto rispecchia la
lingua di `instructions`/`query`. Scope unico per tutto: `generate:write`.

```python
accepted = ando.generate(
    "docx",
    "Prepara un report sul fatturato 2025 con sintesi e dettaglio.",
    query="fatturato 2025",
)
gen = ando.wait_for_generation(accepted["generation_id"])   # -> completed | GenerationFailed
artifact = ando.download_generation(gen["generation_id"])   # DownloadedFile
artifact.save("report.docx")                                # bytes su disco
```

`wait_for_generation` condivide identica policy di retry/backoff di
`wait_for_job` (transient `rate_limited`/5xx/rete ritentati entro la deadline);
solleva `GenerationFailed` (con `error_code`) su `failed`, `GenerationTimeout`
allo scadere.

## CAD — analisi, DFM, conversione

Upload STEP/DXF asincroni (`202` con `job_id` + `result_id`). Analisi e DFM
tornano un **report JSON**, la conversione un **file STEP**. Scope `cad:read`
per analyze/dfm-check/similar/jobs/results, `cad:write` per convert. Un job CAD
**è un job**: `wait_for_cad_job` riusa `JobFailed`/`JobTimeout`.

```python
job = ando.cad_analyze("bracket.step")                      # {result_id, job_id, kind: "analysis"}
ando.wait_for_cad_job(job["job_id"])
report = ando.get_cad_result(job["result_id"]).json()       # report JSON

conv = ando.cad_convert("profilo.dxf", "extrude", params={"height_mm": 10})
ando.wait_for_cad_job(conv["job_id"])
ando.get_cad_result(conv["result_id"]).save("solido.step")  # STEP su disco
```

`cad_find_similar()` è la **ricerca per somiglianza query-by-upload** (live):
carichi uno STEP/IGES/STL, un disegno PDF o un'immagine; viene embeddato al
volo e confrontato con il corpus CAD ingerito del project. Il file di query
non viene mai salvato. La chiamata è **sincrona** — uno STEP grande può
richiedere ~10-30s di tessellazione, dimensiona il timeout di conseguenza.

```python
simili = ando.cad_find_similar("staffa.step", top_k=5)
for hit in simili["results"]:
    print(hit["filename"], hit["similarity"], hit["description"])
```

## Azioni — il write-loop

La metà **write** simmetrica della Push API: chiedi all'app del project di
*fare* qualcosa (aprire un ticket, spostare un ordine) e ne verifichi
l'esito. `declare` (una tantum, `sources:manage`) → `propose` → `execute`
(`actions:write`) → l'app fa `complete`. Le letture usano `actions:read`.

```python
actions = ando.actions()

# dichiarazione una tantum (chiave sources:manage)
actions.declare(
    "acme-desk",
    "create_ticket",
    params_schema={
        "type": "object",
        "properties": {"subject": {"type": "string"}},
        "required": ["subject"],
    },
    risk="medium",
)

# per richiesta (chiave actions:write) — propose + execute in una chiamata
dispatched = actions.run(
    "acme-desk",
    "create_ticket",
    {"subject": "Refund request"},
    idempotency_key="refund-4711",   # un run ritentato replay-a la proposta
)
print(dispatched["id"], dispatched["status"])   # ... "dispatched"
```

Le azioni `high`/`critical` richiedono `confirm_risk=True` su
`run`/`execute`. `propose_action` accetta un `idempotency_key` opzionale
(header `Idempotency-Key`): una propose ritentata con la stessa chiave
replay-a il primo `201` invece di duplicare. Ogni metodo esiste anche come
funzione di modulo (`propose_action(client, ...)`) e come delega sul client
(`ando.propose_action(...)`).

### Capability con binding HTTP — dichiara, poi `invoke`

Dichiara la capability **con un `binding` HTTP** e Ando può chiamare
direttamente il tuo sistema: `invoke_connector_action` è il fratello
sincrono di propose + execute — un round trip, esito nella risposta.

```python
# dichiarazione CON binding (chiave sources:manage)
actions.declare(
    "acme-desk",
    "create_ticket",
    params_schema={
        "type": "object",
        "properties": {"subject": {"type": "string"}},
        "required": ["subject"],
    },
    risk="medium",
    binding={
        "method": "POST",
        "path": "/tickets",                        # relativo, sul TUO sistema
        "body_template": {"title": "{{subject}}"}, # placeholder {{param}}
        "response": {"id_field": "id"},            # dove vive l'id creato
        "timeout_seconds": 15,
    },
)

# invoke sincrono (chiave actions:write)
esito = ando.invoke_connector_action(
    "acme-desk", "create_ticket", {"subject": "Refund request"}
)
print(esito["ok"], esito["status_code"], esito["response"])
```

I `params` sono validati contro il `params_schema` dichiarato e vale lo
**stesso risk gate di execute**: una capability `high`/`critical` viene
rifiutata (`invalid_request`) senza `confirm_risk=True`. Le chiavi di test
validano e gate-ano per davvero ma **non** chiamano mai l'endpoint bound —
la risposta porta `test: true`. La dichiarazione è una sostituzione
completa: ri-dichiarare senza `binding` cancella quello memorizzato.

## Flows — orchestrazione come API

Costruisci, esegui e supervisiona gli stessi flow del builder prodotto,
white-label. Scope: `flows:read` (list/palette/stats/inbox/run detail) e
`flows:manage` (create/update/delete/run/approve/reject).

```python
palette = ando.flows_palette()          # ogni node type + campi di config
flows = ando.list_flows(page=1, page_size=20)
draft = ando.generate_flow(
    "Quando arriva una fattura, valida i dati e chiedi approvazione"
)  # copilot: NL -> draft flow (costo LLM, metered)

created = ando.create_flow(draft["flow"])
ando.run_flow(created["flow_id"], dry_run=True)
ando.flow_stats(created["flow_id"])
ando.update_flow(created["flow_id"], {"is_active": False})

# run manuale seminato con un PDF caricato: il suo content_item_id finisce
# nel trigger_data del run per i nodi che bindano
# {"$ref": "trigger.content_item_id"} (es. ai.cad_analyze)
run = ando.run_flow_with_upload(created["flow_id"], "fattura.pdf")

# polling di un run: stato, trigger, context accumulato, output per nodo
detail = ando.get_flow_run(run["run_id"])
print(detail["status"], detail["node_runs"])

# HITL: gli step `logic.approval` parcheggiano il run in `pending_approval`
inbox = ando.flows_pending_approvals()
ando.approve_flow_run("run_...", edited_value="testo corretto")
ando.approve_flow_run(
    "run_...",
    # correzione strutturata per-campo dello STESSO output editabile — se
    # arrivano entrambi, edited_fields vince sulle chiavi sovrapposte
    edited_fields={"to": "acme@example.com", "subject": "Ordine 42"},
)
ando.reject_flow_run("run_...", reason="importo errato")
```

`run_flow_with_upload` accetta solo PDF (`invalid_request` su tutto il
resto). `flow_templates()` elenca la galleria di template;
`delete_flow(flow_id)` rimuove un flow.

## Usage — consumi e costi del project

`usage()` (`usage:read`) ritorna chiamate/token/costo aggregati del project —
mese corrente di default, `group_by="day"` per la serie di fatturazione o
`"purpose"` per capire cosa genera costo:

```python
report = ando.usage(from_="2026-08-01", to="2026-08-07")
print(report["total_cost_usd"], report["usage"])
```

## Sources — cosa può interrogare questa chiave

Prima di scopare una `/query` (come fa la UI prodotto lasciando scegliere un
connettore) bisogna **scoprire** cosa la chiave può interrogare. `list_sources()`
lo dice, già intersecato con la resource policy della chiave.

```python
for s in ando.list_sources()["sources"]:
    print(s["id"], s["kind"], s["queryable"], s.get("reason"))
    # id unstrutturati (fs/drive/…) -> filters.sources di /query
    # id strutturati (conn_<uuid>)  -> sources di /agentic/answer
```

Registra un database read-only del cliente (es. il back office SQL di un
gestionale) come source strutturata — le credenziali sono cifrate at rest e
**mai** ritornate; la risposta è white-label (capability, mai il vendor):

```python
source = ando.register_database_source(
    name="Gestionale produzione",
    connector_type="mysql",   # postgresql | mysql | sqlserver | oracle | ...
    credentials={"host": "db.example.com", "user": "ando_ro",
                 "password": "…", "database": "mexal"},
    config={"schema_filter": ["mexal"], "max_rows_per_query": 500},
)
ando.test_source(source["source_id"])   # {ok, error?} — probe, non un ApiError
```

Per una source **strutturata** lo schema profilato si cura e poi si
**pubblica** (il gate di queryabilità): finché non è pubblicato,
`queryable=false, reason="schema non pubblicato"`. Curare = `sources:manage`,
leggere lo schema = `query:read`.

```python
ando.get_source_schema("conn_…")                 # stato + objects (K-Schema)
ando.curate_source_schema(                        # override della proposta AI
    "conn_…", "public.orders",
    description="Ordini di vendita",
    column_descriptions={"total": "Importo totale, IVA inclusa"},
)
ando.publish_source_schema("conn_…")              # apre /query + /agentic
```

## Webhooks

Endpoint HTTPS in uscita, firmati Standard Webhooks. Scope `keys:manage`. Il
`secret` (`whsec_…`) è mostrato **una sola volta** alla creazione.

```python
hook = ando.create_webhook("https://example.com/hooks", ["ingestion.completed"])
print(hook["secret"])        # salvalo ora — non torna più
ando.list_webhooks()         # mai il secret; cursor/limit opzionali
ando.test_webhook(hook["webhook_id"])            # test.ping sincrono

# storico deliveries + replay manuale (firma fresca, tentativo immediato)
deliveries = ando.list_webhook_deliveries(hook["webhook_id"], limit=100)
ando.replay_webhook_delivery(deliveries["deliveries"][0]["delivery_id"])

ando.delete_webhook(hook["webhook_id"])
```

## Riferimento

Costruttore:

```python
AndoPlatform(
    api_key,                                        # ando_sk_live_... / ando_sk_test_...
    base_url="https://api.ando-ai.com/platform/v1",
    timeout=30,                                     # secondi, per richiesta
)
```

| Metodo | Endpoint | Scope | Note |
|---|---|---|---|
| `health()` | `GET /health` | — | liveness pubblica |
| `me()` | `GET /me` | chiave valida | identità del project |
| `upload_document(path_or_bytes, filename=None, content_type=None, metadata=None, partition=None)` | `POST /documents` | `ingest:write` | multipart; accetta un path (filename dedotto) o `bytes` (filename obbligatorio); `metadata`/`partition` opzionali diventano parti multipart solo se valorizzati; ritorna `{document_id, job_id, status}` (202) |
| `get_document(id)` | `GET /documents/{id}` | `query:read` | stato + metadati |
| `delete_document(id)` | `DELETE /documents/{id}` | `ingest:write` | rimozione indice + storage |
| `get_job(id)` | `GET /jobs/{id}` | `query:read` | `queued → processing → completed \| failed` |
| `wait_for_job(id, timeout=120, poll=2.0)` | polling su `GET /jobs/{id}` | `query:read` | ritorna il job a `completed`; solleva `JobFailed` (con `error_code`) su `failed`, `JobTimeout` allo scadere |
| `query(text, mode="retrieve", k=10, filters=None, sources=None, partition=None, include_web=False, verify=False)` | `POST /query` | `query:read` | `k` max 50 da contratto; i campi opzionali finiscono nel body solo se valorizzati |
| `answer(text, k=10, ...)` | `POST /query` | `query:read` | scorciatoia per `mode="answer"`, stessi parametri di narrowing |
| `agentic_answer(query, k=10, sources=None, max_steps=None, with_diagnostics=False, include_rows=False, conversation_id=None, timeout=120)` | `POST /agentic/answer` | `query:read` | **PREVIEW** — loop agentico read-only; ritorna `{answer, citations, sources_used, steps, diagnostics}` |
| `create_conversation(title=None)` | `POST /conversations` | `query:read` | apre un thread di memoria per i turni agentici |
| `list_conversations(cursor=None, limit=None)` | `GET /conversations` | `query:read` | thread della chiave, cursor-paginati |
| `get_conversation_turns(id, cursor=None, limit=None)` | `GET /conversations/{id}` | `query:read` | scambi del thread, dal più vecchio |
| `delete_conversation(id)` | `DELETE /conversations/{id}` | `query:read` | irreversibile |
| `connectors()` | `GET /connectors` | `sources:manage` | elenco connector del project |
| `connector(slug, **options)` | — | — | costruisce un `AndoConnector` legato a questo client (Push API) |
| `connector_kinds()` | `GET /connectors/kinds` | chiave valida | kind disponibili + contratto Record |
| `validate_records(records, connector=None)` | `POST /connectors/validate` | `ingest:write` | dry-run dei record, nessuna scrittura |
| `update_connector(slug, name=None, config=None, enabled=None)` | `PATCH /connectors/{slug}` | `sources:manage` | rename/enable/config (sostituita intera); rotazione secret per i manifest connector |
| `actions()` | — | — | costruisce un `AndoActions` legato a questo client (`run(...)` = propose + execute) |
| `declare_connector_action(connector, name, description=None, params_schema=None, risk=None, binding=None)` | `POST /connectors/{slug}/actions` | `sources:manage` | idempotente su `name`; `binding` = binding HTTP opzionale (abilita l'invoke); ri-dichiarare senza `binding` lo cancella |
| `connector_actions(connector)` | `GET /connectors/{slug}/actions` | `actions:read` | azioni dichiarate del connector |
| `delete_connector_action(connector, name)` | `DELETE /connectors/{slug}/actions/{name}` | `sources:manage` | rimuove una dichiarazione |
| `invoke_connector_action(connector, name, params=None, *, confirm_risk=False)` | `POST /connectors/{slug}/actions/{name}/invoke` | `actions:write` | invoke sincrono della capability bound: `{ok, status_code, response}`; risk gate di execute (`high`/`critical` rifiutati senza `confirm_risk=True`); chiave test = `test: true`, endpoint mai chiamato |
| `propose_action(connector, action, params=None, idempotency_key=None)` | `POST /actions` | `actions:write` | valida + preview, nessun side effect; `Idempotency-Key` opzionale |
| `execute_action(action_id, confirm_risk=False)` | `POST /actions/{id}/execute` | `actions:write` | dispatch del webhook `action.requested`; `high`/`critical` richiedono `confirm_risk` |
| `complete_action(action_id, status, result=None, error=None)` | `POST /actions/{id}/complete` | `actions:write` | l'app riporta l'esito (`completed`/`failed`) |
| `get_action(action_id)` | `GET /actions/{id}` | `actions:read` | polling di una richiesta |
| `list_actions(connector=None, limit=None, cursor=None)` | `GET /actions` | `actions:read` | richieste del project, cursor-paginate |
| `generate(format, instructions, query=None, document_ids=None, k=10)` | `POST /generate` | `generate:write` | job async; serve almeno uno tra `query`/`document_ids`; ritorna `{generation_id, document_id, status}` (202) |
| `get_generation(id)` | `GET /generate/{id}` | `generate:write` | stato; `download_url` a `completed` |
| `wait_for_generation(id, timeout=180, poll=2.0)` | polling su `GET /generate/{id}` | `generate:write` | ritorna il payload a `completed`; `GenerationFailed`/`GenerationTimeout` |
| `download_generation(id)` | `GET /generate/{id}/download` | `generate:write` | `DownloadedFile` (bytes + content type + filename) |
| `cad_analyze(path_or_bytes, ...)` | `POST /cad/analyze` | `cad:read` | STEP/DXF; job async `kind="analysis"` |
| `cad_dfm_check(path_or_bytes, ruleset_name=None, ...)` | `POST /cad/dfm-check` | `cad:read` | solo STEP; `ruleset_name` inviato solo se valorizzato |
| `cad_convert(path_or_bytes, operation, params=None, target="step", ...)` | `POST /cad/convert` | `cad:write` | DXF→STEP; `operation` `extrude`/`revolve`; params validati server-side |
| `cad_find_similar(path_or_bytes, filename=None, content_type=None, top_k=8)` | `POST /cad/similar` | `cad:read` | ricerca per somiglianza query-by-upload; sincrona, il file di query non viene salvato |
| `get_cad_job(id)` | `GET /cad/jobs/{id}` | `cad:read` | stato job CAD |
| `wait_for_cad_job(id, timeout=180, poll=2.0)` | polling su `GET /cad/jobs/{id}` | `cad:read` | `JobFailed`/`JobTimeout` (un job CAD è un job) |
| `get_cad_result(id)` | `GET /cad/results/{id}` | `cad:read` | `DownloadedFile`; `.json()` per analisi/DFM, `.save()` per STEP |
| `list_sources()` | `GET /sources` | `query:read` | cosa la chiave può interrogare (già intersecato con la policy) |
| `register_database_source(name=..., connector_type=..., credentials=..., config=None)` | `POST /sources/database` | `sources:manage` | registra un DB read-only del cliente; credenziali cifrate, mai ritornate; risposta white-label |
| `test_source(source_id)` | `POST /sources/{id}/test` | `sources:manage` | probe di connessione; un probe fallito è `{ok: false}` nel body 200, non un ApiError |
| `get_source_schema(source_id)` | `GET /sources/{id}/schema` | `query:read` | K-Schema profilato di una source strutturata |
| `curate_source_schema(source_id, object_name, description=None, column_descriptions=None, semantic_mappings=None)` | `PATCH /sources/{id}/schema/{object}` | `sources:manage` | override curazione AI; almeno un campo (altrimenti `ValueError`) |
| `publish_source_schema(source_id, published=True)` | `POST /sources/{id}/schema/publish` | `sources:manage` | gate di queryabilità; `published=False` per revertire |
| `create_webhook(url, events)` | `POST /webhooks` | `keys:manage` | il `secret` appare SOLO qui (201) |
| `list_webhooks(cursor=None, limit=None)` | `GET /webhooks` | `keys:manage` | mai il secret; cursor-paginato con `cursor`/`limit` |
| `delete_webhook(id)` | `DELETE /webhooks/{id}` | `keys:manage` | rimozione; deliveries pending scartate |
| `test_webhook(id)` | `POST /webhooks/{id}/test` | `keys:manage` | `test.ping` sincrono; esito nel body 200 |
| `list_webhook_deliveries(webhook_id, limit=50)` | `GET /webhooks/{id}/deliveries` | `keys:manage` | storico deliveries, dal più recente |
| `replay_webhook_delivery(delivery_id)` | `POST /webhooks/deliveries/{id}/replay` | `keys:manage` | re-invio immediato con firma fresca; anche su righe `delivered` |
| `flows_palette()` | `GET /flows/palette` | `flows:read` | node type costruibili + campi di config |
| `flow_templates()` | `GET /flows/templates` | `flows:read` | galleria di template |
| `list_flows(page=1, page_size=20, status=None)` | `GET /flows` | `flows:read` | flow del project, paginati |
| `create_flow(flow)` | `POST /flows` | `flows:manage` | stesso contratto `FlowCreate` del builder prodotto (o `template_id`) |
| `get_flow(flow_id)` | `GET /flows/{id}` | `flows:read` | definizione completa |
| `flow_stats(flow_id)` | `GET /flows/{id}/stats` | `flows:read` | contatori/esiti dei run |
| `update_flow(flow_id, patch)` | `PATCH /flows/{id}` | `flows:manage` | update parziale, incl. `is_active` |
| `delete_flow(flow_id)` | `DELETE /flows/{id}` | `flows:manage` | rimozione |
| `run_flow(flow_id, dry_run=False, trigger_data=None)` | `POST /flows/{id}/run` | `flows:manage` | run manuale; gli step approval parcheggiano il run |
| `run_flow_with_upload(flow_id, path_or_bytes, *, filename=None, content_type=None, dry_run=False)` | `POST /flows/{id}/run-upload` | `flows:manage` | run manuale seminato da un PDF caricato (`content_item_id` nel `trigger_data`); solo PDF |
| `get_flow_run(run_id)` | `GET /flows/runs/{id}` | `flows:read` | dettaglio completo di un run: stato, trigger, `context`, `node_runs`, campi HITL |
| `generate_flow(prompt)` | `POST /flows/generate` | `flows:manage` | copilot NL → draft flow (costo LLM, metered) |
| `flows_pending_approvals()` | `GET /flows/pending-approvals` | `flows:read` | inbox HITL dei run in `pending_approval` |
| `approve_flow_run(run_id, *, edited_value=None, edited_fields=None)` | `POST /flows/runs/{id}/approve` | `flows:manage` | riprende un run in pausa; `edited_value` = una stringa, `edited_fields` = correzione strutturata per-campo (vince sulle chiavi sovrapposte) |
| `reject_flow_run(run_id, *, reason=None)` | `POST /flows/runs/{id}/reject` | `flows:manage` | "no" terminale: il run non riprende mai |
| `usage(from_=None, to=None, group_by="day")` | `GET /usage` | `usage:read` | chiamate/token/costo aggregati; `group_by` `day`/`purpose` |
| `create_key(name, scopes, live=True, expires_at=None)` | `POST /keys` | `keys:manage` | la chiave completa appare SOLO in questa risposta |
| `list_keys()` | `GET /keys` | `keys:manage` | mai hash o chiavi complete |
| `revoke_key(id)` | `POST /keys/{id}/revoke` | `keys:manage` | revoca immediata |

Tutti i metodi ritornano il body JSON della risposta come `dict`, tranne
`download_generation` / `get_cad_result` che ritornano un `DownloadedFile`
(`content` bytes, `content_type`, `filename`, più `.json()` e `.save(path)`).

Costanti esportate: `DEFAULT_K` (10), `MAX_K` (50), `FILTER_SOURCES`,
`GENERATE_FORMATS`, `WEBHOOK_EVENTS`, `ACTION_RISK_LEVELS` (tutte
informative: i valori enum sono additivi, il SDK non li valida),
`AGENTIC_TIMEOUT` (120s), `DEFAULT_BATCH_SIZE` (200), `MAX_BATCH_SIZE`
(500), `DEFAULT_BASE_URL`. Tipi esportati: `ActionBinding` (il binding HTTP
di una capability — un normale `dict` con quelle chiavi va bene ovunque).

## CLI (`ando`)

Il pacchetto espone lo script `ando` (o `python -m ando_ai`):

```bash
ando kinds                                   # kind disponibili + contratto Record
ando validate records.jsonl                  # dry-run dei record (nessuna scrittura)
cat records.jsonl | ando validate -          # …da stdin (JSON array o JSONL)
ando sync acme-crm records.jsonl --mode full # push di un batch
ando sources                                 # cosa può interrogare questa chiave
ando me                                      # identità della chiave
ando mcp --client cursor                     # config MCP per il tuo agente
```

Auth: `--api-key` o `$ANDO_API_KEY`; base URL: `--base-url` o `$ANDO_BASE_URL`.
`ando validate` consuma `POST /connectors/validate` e **esce con codice 1** se un
record è rifiutato — la CI di un connettore può fare da gate sul contratto.
`ando mcp` stampa l'install one-command del server MCP hosted (Claude Code /
Cursor / Claude Desktop); senza `--client` le stampa tutte.

## Errori

Gerarchia (base comune `AndoPlatformError`):

- **`ApiError`** — errore dell'envelope `{"error": {"code", "message"}}`.
  Attributi: `code` (stabile: `invalid_api_key`, `expired_api_key`,
  `revoked_api_key`, `insufficient_scope`, `rate_limited`,
  `invalid_request`, `not_found`, `internal_error`), `message`, `status`
  e `retry_after` (secondi dall'header `Retry-After`, valorizzato sui 429).
- **`NetworkError`** — problema di trasporto (DNS, connessione, TLS,
  timeout); l'eccezione `httpx` originale è in `__cause__`. Su
  `agentic_answer` un read timeout arriva qui: di solito significa
  «alza `timeout=`», non «il server è rotto».
- **`JobFailed`** — job di ingestion (o CAD) terminato `failed`; porta
  `error_code`, `error_message` e il payload completo in `job`.
- **`JobTimeout`** — `wait_for_job` / `wait_for_cad_job` ha esaurito il
  `timeout` senza uno stato terminale.
- **`GenerationFailed`** — generazione terminata `failed`; porta `error_code`,
  `error_message` e il payload in `generation`.
- **`GenerationTimeout`** — `wait_for_generation` ha esaurito il `timeout`.

```python
from ando_ai import AndoPlatform, ApiError

with AndoPlatform("ando_sk_live_...") as ando:
    try:
        ando.query("scadenze")
    except ApiError as exc:
        if exc.code == "rate_limited":
            print(f"Riprova tra {exc.retry_after}s")
        elif exc.code == "insufficient_scope":
            print(f"Scope mancante: {exc.message}")
        else:
            raise
```

## Test

I test del SDK girano senza rete né backend (`httpx.MockTransport`):

```bash
uv run pytest tests/unit/test_platform_sdk_client.py
```

## License

Proprietary — Copyright (c) 2026 Ando S.r.l. This SDK is licensed for use
solely with the Ando Platform services; it is **not** open source. See the
`LICENSE` file shipped in this package for the full terms.
