edoardoguzzi.com_
Realizzazione siti web e applicazioni professionali utili per la tua azienda

> Agent Skills di Anthropic dentro n8n: come si fa davvero

Table Of Contents

Succede un giovedì. L’agent risponde male da una settimana, e quando apri finalmente il system prompt capisci perché: ottomila token, tutto in un blocco. Sempre in contesto. Anche quando l’utente chiede solo di tradurre una mail.

Si chiama prompt bloat. Ed è il modo più rapido per rendere un AI Agent stupido e caro nello stesso istante.

Negli ultimi mesi ho lavorato a più riprese sullo stesso assistente interno per WebWakeUp, e ogni volta sono finito nello stesso muro: un solo agent che deve fare cose troppo diverse (scrivere meta description nello stile del cliente X, generare PDF su template, fare ricerca competitor, rispondere a ticket di supporto), e un system prompt che cresce a ogni nuova competenza aggiunta. Il modello inizia a confondere le regole, le risposte si appiattiscono, i costi salgono. Classico.

Poi ho scremato la documentazione di Anthropic sulle Agent Skills, ho tenuto le parti che servivano e ho provato a portare il pattern dentro n8n self-hosted. Funziona. Funziona bene. Ma con qualche distinguo che non sto trovando scritto in giro, e quindi qui ne parliamo.

Quello che segue è una lettura tecnica, in versione lavori in corso. Sto ancora testando, quindi prendete il tutto come un cantiere aperto, non come un manuale finito. Se vedete cose che non tornano, scrivetemi: il pezzo è pensato proprio per quello.

Il problema, in tre righe

Un AI Agent ha un costo per turno proporzionale a quanto è grande il suo system prompt. Se nel system prompt ci infili tutte le istruzioni per tutti i task possibili, paghi quel prezzo anche quando l’utente sta chiedendo l’ora. Se invece le tieni fuori, l’agent non sa fare niente di specifico. È un problema vecchio quanto i primi prompt elaborati, ed è il punto in cui Anthropic ha messo la mano per primo, in modo pulito.

La risposta che hanno costruito si chiama progressive disclosure, traducibile come “carico del contesto a strati”. L’agent vede solo un manifesto piccolissimo a tutti i turni, e carica il resto solo quando serve davvero.

Tre livelli, che fanno tutta la differenza

Il pattern Anthropic divide il caricamento del contesto in tre strati. Tenerli separati è la chiave dell’intera storia.

Livello uno, sempre caricato. Solo nome e descrizione di ciascuna skill, una riga a testa. Roba da trenta o cinquanta token a skill. Costa poco, vive nel system prompt, serve all’LLM per fare riconoscimento (capire se la richiesta dell’utente combacia con qualcuna delle competenze disponibili).

Livello due, caricato su match. Quando l’LLM riconosce una skill rilevante, chiama un tool che gli restituisce le istruzioni complete di quella skill. Cinquecento, mille, duemila token: dipende dalla skill. Entrano nel contesto solo per quel turno (o quei turni successivi finché serve), non vengono ricaricati a ogni richiesta.

Livello tre, caricato a richiesta della skill stessa. Una skill può, dentro le sue istruzioni, dire “se la situazione è X, leggi anche il file di esempi”. L’agent chiama un secondo tool, scarica i riferimenti aggiuntivi, e li tiene nel contesto solo finché gli servono. Esempi lunghi, casi limite, tabelle di riferimento, vivono qui.

Il risultato è che il system prompt resta piccolo a vita, anche con cinquanta o cento skill in catalogo. Paghi i token in più solo quando l’utente fa una richiesta che li giustifica. Niente acqua, niente costi a vuoto.

Anthropic ovviamente lo serve nei suoi prodotti (Claude Code, le sue Skills, l’Agent SDK). La domanda è: si può replicare lo stesso pattern dentro n8n, senza inventarsi nulla? La risposta è sì, e si fa quasi tutto coi nodi nativi.

Replicarlo dentro n8n, coi nodi nativi

Il giocattolo si monta con quattro pezzi: AI Agent (versione 3.1, dalla 1.82.0 in poi è Tools Agent fisso e va bene così), Call n8n Workflow Tool (versione 2.2), Execute Workflow Trigger (versione 1.1), Postgres (versione 2.6). Più un piccolo Code node per costruire il manifesto. Stop.

L’idea è semplice. I miei “skill files” non vivono come .md sul filesystem (come fa Anthropic), ma come righe di una tabella Postgres. Due tabelle, in realtà: una per le skill, una per i loro riferimenti aggiuntivi. Il MAIN_AGENT, all’avvio di ogni sessione, fa una SELECT di tutte le skill attive e si costruisce il manifesto sul momento. I sub-workflow esposti come tool servono al modello per chiedere “dammi la skill X” o “dammi il riferimento Y della skill X” quando ne ha bisogno.

Niente codice custom, niente plugin esterni, niente API in più da mantenere. Solo nodi.

Postgres sì. Data Tables no (e dico perché)

Qui mi gioco un’opinione, e tengo le porte aperte: io la leggo così, ditemi voi.

n8n ha le Data Tables integrate da un paio di versioni. Tabelle native, gestite dall’interfaccia, con un endpoint API REST esposto (/datatables) per interagirci anche da fuori, import/export CSV nativo, accesso scoped per progetto. Per prototipare un sistema così sono perfette: ti alzi un workflow in mezz’ora, le righe le editi cliccando, e se ti serve un pannellino esterno l’API c’è.

Però per metterci sopra un sistema di skill che pretendiamo di tenere in piedi mesi, anni, e far crescere, ci sono limitazioni documentate che vanno pesate. Niente di drammatico, niente “scatola chiusa”, ma limiti precisi che cambiano la scelta.

Tipi di colonna: Boolean, Date, Number, String. Niente JSON, niente VECTOR, niente BLOB. Quindi niente embedding sulla stessa tabella, niente payload strutturato arbitrario.

Filtri: Equals, Not Equals, Greater Than e Less Than (con o senza uguale), Is Empty, Is Not Empty. Niente LIKE, niente full-text, niente similarity. Per il pattern matching delle skill recuperi il manifesto e amen, niente pre-filtraggio server-side.

Operazioni: Insert, Update, Upsert, Delete, Get sulle righe. Create, Delete, List, Update sulle tabelle. Niente SQL grezzo.

Storage cap: di default 50MB per tutte le Data Tables di un’istanza. Su self-hosted alzabile via la variabile d’ambiente N8N_DATA_TABLES_MAX_SIZE_BYTES. n8n avvisa all’80%, blocca insert e update al 100%. Per un catalogo di skill testuali può bastare a lungo, ma è un soffitto da tenere d’occhio.

Niente accesso dal Code node: citazione verbatim dalla docs, “Direct programmatic access to data tables from a Code node isn’t supported.” Per l’architettura che ti ho descritto qui sopra, dove un Code node impacchetta il manifesto, questo è un problema diretto. Lavorabile (Set node dopo un Get-many al posto del Code), ma cambia forma.

Su tre cose la docs non si esprime, e quindi non lo affermo: versioning nativo, comportamento sotto concorrenza pesante, e se i backup di n8n includono i contenuti delle Data Tables. Non lo so, vado a verificarlo. Se hai riferimenti precisi su questi tre punti, scrivimi.

Tirando le somme, Postgres lo prendo lo stesso, e le ragioni sono cinque, tutte concrete.

Primo, il cap di 50MB lo voglio fuori dalla testa. Una skill registry che potrebbe crescere su versioning e cronologia non la voglio dipendente da una soglia da monitorare.

Secondo, voglio la similarity search server-side il giorno che le skill saranno cento o più. pgvector è pronto, sulle Data Tables nessun tipo VECTOR all’orizzonte.

Terzo, voglio il Code node libero di leggere e impacchettare i dati. Mi serve oggi per la pipeline AI Agent e mi servirà domani per pipeline di export e backup.

Quarto, voglio SQL crudo per il giorno che mi serve un’aggregazione, una JOIN, un trigger di storia. Il trigger di storia in Postgres è una funzione di dieci righe, sulle Data Tables va costruito a mano in workflow.

Quinto, mi va bene un container in più. La mia istanza n8n vive già in docker compose, aggiungere un Postgres con immagine pgvector/pgvector:pg16 non costa niente in attriti operativi.

Se l’obiettivo fosse una rubrica piccola, dieci o venti skill fisse senza pretese di crescita, le Data Tables sarebbero una scelta perfettamente difendibile. Nel mio caso, con un orizzonte di crescita che non conosco ancora ma che voglio non bloccare, ho preso il container in più.

Magari mi sbaglio. Se qualcuno ha portato un sistema di skill su Data Tables a scala vera (cento, duecento entry, con history e admin esterno via l’API /datatables), mi piacerebbe sentirlo: il dibattito è aperto.

Lo schema Postgres, con pgvector dal primo giorno

Container dedicato, non riusare quello di n8n. Voglio poter fare backup separato, voglio non sporcare il database operativo di n8n con i miei dati applicativi, voglio poter migrare un giorno tutto il giocattolo da un’altra parte senza dover smontare n8n. È più pulito così.

Punto importante che mi sono confermato dopo: l’immagine giusta è pgvector/pgvector:pg16, non postgres:16-alpine. È Postgres standard più l’estensione pgvector preinstallata. Senza l’estensione abilitata, si comporta come un Postgres vanilla, zero overhead. Però il giorno che vuoi attivare la similarity search per il salto a manifesto-via-RAG, ci sei già: niente migrazione dell’immagine, niente pg_dump a freddo, niente tempo perso.

services:
  wwu-skills-db:
    image: pgvector/pgvector:pg16
    container_name: wwu-skills-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: wwu_skills
      POSTGRES_USER: wwu_skills_user
      POSTGRES_PASSWORD: ${WWU_SKILLS_DB_PASSWORD}
    volumes:
      - wwu_skills_data:/var/lib/postgresql/data
    networks:
      - n8n_network
volumes:
  wwu_skills_data:
networks:
  n8n_network:
    external: true

Niente porta esposta sull’host. Solo n8n parla con questo Postgres, sulla rete docker interna. La password vive in una variabile d’ambiente, non nel file.

Lo schema vero e proprio. Tre tabelle: skills, skill_references, skills_history. Più un trigger che ad ogni UPDATE archivia la versione precedente, così il versioning ce l’hai gratis.

CREATE SCHEMA IF NOT EXISTS skills_mgmt;
 
-- Day 1: enable the extension. Zero cost if not used.
CREATE EXTENSION IF NOT EXISTS vector;
 
CREATE TABLE skills_mgmt.skills (
    name        VARCHAR(80)  PRIMARY KEY,
    description TEXT         NOT NULL,
    content     TEXT         NOT NULL,
    active      BOOLEAN      NOT NULL DEFAULT TRUE,
    version     INTEGER      NOT NULL DEFAULT 1,
    -- Embedding column, nullable for now. Dimension matches your embedding
    -- model: 1536 for text-embedding-3-small, 3072 for text-embedding-3-large.
    description_embedding vector(1536),
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    CHECK (name ~ '^[a-z0-9-]+$')
);
 
CREATE TABLE skills_mgmt.skill_references (
    id              SERIAL PRIMARY KEY,
    skill_name      VARCHAR(80) NOT NULL
                    REFERENCES skills_mgmt.skills(name) ON DELETE CASCADE,
    reference_name  VARCHAR(120) NOT NULL,
    content         TEXT NOT NULL,
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (skill_name, reference_name),
    CHECK (reference_name ~ '^[a-z0-9_.-]+$')
);
 
CREATE TABLE skills_mgmt.skills_history (
    id          BIGSERIAL PRIMARY KEY,
    name        VARCHAR(80) NOT NULL,
    description TEXT,
    content     TEXT,
    version     INTEGER,
    archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE OR REPLACE FUNCTION skills_mgmt.fn_skills_history()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO skills_mgmt.skills_history(name, description, content, version)
    VALUES (OLD.name, OLD.description, OLD.content, OLD.version);
    NEW.version := OLD.version + 1;
    NEW.updated_at := NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
CREATE TRIGGER trg_skills_history
BEFORE UPDATE ON skills_mgmt.skills
FOR EACH ROW EXECUTE FUNCTION skills_mgmt.fn_skills_history();
 
CREATE INDEX idx_skills_active
    ON skills_mgmt.skills(active) WHERE active = TRUE;

I CHECK sui nomi non sono strettamente necessari, perché le query sono parametrizzate dal nodo Postgres di n8n e l’iniezione è già coperta. Ma sono cintura più bretelle: se qualcuno bypassa i workflow e inserisce a mano, almeno il database non lascia passare identificativi strani.

L’indice HNSW per la similarity search lo si crea solo quando hai davvero popolato la colonna description_embedding. Crearlo a vuoto su una colonna NULL non serve a niente, e occupa memoria.

I tre workflow

Tre workflow separati. Uno principale, due piccolissimi che fanno solo da tool.

Workflow A: tool_load_skill

Tre nodi e basta. Execute Workflow Trigger con inputSource: workflowInputs e un solo input dichiarato { name: "skill_name", type: "string" }. Postgres in modalità executeQuery con la query qui sotto. Un nodo Set finale che normalizza l’output, restituendo o { skill_name, content } oppure { error }.

SELECT name, description, content
FROM skills_mgmt.skills
WHERE name = $1 AND active = TRUE
LIMIT 1;

Il parametro $1 si popola con options.queryReplacement uguale a ={{ $json.skill_name }}. Niente concatenazione di stringhe nella query, niente iniezione possibile, n8n sanitizza il valore prima di passarlo al driver. Documentato, controllato, dimenticato.

Workflow B: tool_load_reference

Identico al precedente, due input invece di uno: skill_name e reference_name. Query con due parametri, queryReplacement uguale a ={{ $json.skill_name }},={{ $json.reference_name }}.

SELECT content
FROM skills_mgmt.skill_references
WHERE skill_name = $1 AND reference_name = $2
LIMIT 1;

Workflow C: il MAIN_AGENT

Il workflow vero, quello che il cliente o il sistema interno colpisce. Quattro nodi in fila più i sub-nodi dell’AI Agent.

  • Chat Trigger (versione 1.4): è la porta d’ingresso. Va bene anche un Webhook se preferisci.
  • Postgres select sulla tabella skills_mgmt.skills con where: active = true, outputColumns: name, description, returnAll: true. Carica il manifesto.
  • Code node per impacchettare il manifesto in una stringa JSON pulita.
  • AI Agent (versione 3.1) con il system message che inietta il manifesto via expression, e i due Tool Workflow agganciati come tool.

Il Code node è l’unico pezzo di codice di tutto il sistema. Niente di esoterico:

/**
 * Build a compact JSON index for the system prompt.
 * Keep description short, every char costs tokens at every turn.
 */
const rows = $input.all().map(i => i.json);
const index = rows.map(r => ({
  name: r.name,
  description: r.description
}));
return [{ json: { skills_index: JSON.stringify(index, null, 2) } }];

Il system message dell’AI Agent. Sì, accetta espressioni n8n: l’ho confermato direttamente sul tipo TypeScript del nodo, systemMessage: string | Expression<string> | PlaceholderValue. Quindi:

You are the WWU AI Agent.
 
BEFORE performing any non-trivial task, scan SKILLS_INDEX below for a matching skill.
If a skill matches the user request, you MUST call the `load_skill` tool with its
exact name BEFORE producing any output. Never invent skill names. Never produce a
deliverable that should use a skill without first loading it.
 
If a loaded skill instructs you to read a reference file, call `load_reference`
only when the current task actually needs that level of detail.
 
SKILLS_INDEX:
{{ $('Build Index').item.json.skills_index }}

I due Tool Workflow vanno configurati con due cose particolarmente pulite. Primo: la description del tool, perché quella la legge l’LLM e decide se chiamarlo. Spendi cinque minuti a scriverla bene, ti risparmia ore di routing sbagliato dopo.

Secondo: i parametri di input del sub-workflow vanno popolati cliccando il pulsante “AI” sul campo, in modo che sia il modello a metterci il valore via $fromAI(). Mai hardcodarli, mai metterci espressioni statiche, sennò il tool diventa muto e l’LLM ci sbatte contro.

Il flusso di una richiesta, end-to-end

L’utente scrive: “scrivimi la meta description per la pagina di email marketing”. Il giro è questo, raccontato nei due tempi che lo compongono.

Tempo uno, la preparazione del contesto. Il Chat Trigger riceve il messaggio. Il nodo Postgres tira fuori le righe delle skill attive (name e description, niente content), il Code node le impacchetta in una stringa JSON da pochi KB, e l’AI Agent parte con il system message già completo di manifesto. Tutto questo è uguale a ogni richiesta, e costa pochissimo.

Tempo due, l’attivazione della skill. Il modello legge il manifesto, riconosce wwu-meta-description, chiama load_skill passando il nome esatto. Il sub-workflow esegue la query parametrizzata e ritorna il contenuto pieno della skill. Il modello lo legge, decide se i riferimenti aggiuntivi gli servono (e nel caso chiama load_reference), poi scrive la meta description seguendo le regole appena caricate.

Il punto, qui sotto.

Il system prompt resta della stessa dimensione che dieci skill o duecento. Il costo per turno cambia poco. Il costo per richiesta sale solo in proporzione a quante skill effettivamente vengono caricate, e di solito è una. Magari due. Mai tutte assieme.

Spigoli vivi che ho beccato (per ora)

La parte onesta del pezzo. Sto testando, e ci sono cose su cui mi vorrei mille opinioni in più.

Sub-node con espressioni e first-item. Le docs di n8n lo dicono in modo esplicito: i sub-node, quando una espression riceve un array di item, risolvono solo al primo. Se per qualche motivo passi più item al Tool Workflow, l’LLM vede solo il primo. Vuol dire: tieni i tool monouso, single-input single-output. Niente batch.

Memoria che si sporca da sola. Se metti una memory buffer lunga, le risposte dei tool finiscono nello storico della conversazione e vengono rimandate al modello a ogni turno successivo. Il contenuto di una skill caricata mezz’ora fa è ancora lì. È un effetto noto del pattern, non un bug, ma se non lo sai te lo ritrovi addosso. Soluzioni che sto provando: usare una memoria a finestra corta, oppure una summary memory. Devo ancora decidere quale regge meglio in produzione.

La qualità della description vale più di quella del content. Il modello sceglie quale skill caricare guardando la description, non il content. Una skill scritta benissimo ma con una description vaga viene ignorata. Una skill scritta così cos ma con una description chiara viene chiamata sempre. Spendete tempo lì, non altrove.

Limiti di dimensione del system message e dei payload. n8n non documenta limiti hard. In pratica il limite te lo mette il modello sottostante. Con duecento skill da una riga di description ciascuna, sei sui sedici KB di manifesto, ancora gestibilissimo. Sopra le cinquecento, conviene saltare al pattern search_skills con embedding, che è il motivo per cui pgvector va messo dal day-one anche se non lo usi subito.

Hot reload delle skill. Il manifesto viene caricato a inizio sessione, non a ogni turno. Se modifichi una skill mentre una conversazione è già aperta, l’LLM continua a vedere la versione vecchia fino a fine sessione. Per il mio caso d’uso va bene, ma se stai progettando un sistema dove gli operatori editano le skill a caldo durante la giornata, ricordatelo.

Cosa fare concretamente, se vuoi provarlo

Cinque passi, in ordine.

  1. Aggiungi al docker-compose accanto a n8n il container pgvector/pgvector:pg16, sulla stessa rete docker, senza esporre porte all’host.
  2. Crea le credenziali Postgres in n8n verso wwu-skills-db, schema skills_mgmt, e applica lo schema SQL di sopra.
  3. Crea il sub-workflow tool_load_skill: tre nodi, copia esatta. Idem per tool_load_reference.
  4. Crea il workflow principale: Chat Trigger, Postgres select, Code node per il manifesto, AI Agent con i due Tool Workflow agganciati. Sul system message metti l’expression che inietta skills_index. Sui parametri dei tool, il pulsante AI.
  5. Inserisci due o tre skill di prova nella tabella, con description differenziate. Manda al chatbot richieste che dovrebbero attivarne una sola alla volta. Guarda nei log degli execution se i tool vengono chiamati nell’ordine giusto e se i contenuti tornano completi.

Il punto cinque è la metà del lavoro vero. Le prime esecuzioni mostreranno le crepe del prompt: routing sbagliato, tool non chiamati, skill caricate quando non servivano. È normale. Si itera sulle description, si itera sul system message, si lima.

Dove va a parare

Il salto naturale, quando le skill superano i tre o quattro digit, è togliere il manifesto piatto dal system prompt e sostituirlo con un terzo tool: search_skills(query, top_k). Internamente fa così: prende la richiesta dell’utente, calcola un embedding tramite il provider che preferisci (OpenAI, Voyage, Cohere, c’è il nodo Embeddings dentro n8n), fa una query vector_cosine_ops sulla tabella skills, ti tira fuori i tre nomi più simili e basta. Il modello vede solo quei tre, ne carica uno, lavora.

L’estensione pgvector ce l’hai già accesa per via dell’immagine, la colonna description_embedding ce l’hai già nullable, devi solo popolarla (un workflow una tantum, oppure un trigger su INSERT e UPDATE che chiama il provider di embedding) e creare l’indice HNSW. Migrazione zero, downtime zero. È il motivo per cui ho insistito a partire con l’immagine pgvector e non con la postgres standard.

Fonti

Chiusura

Sto ancora testando questa architettura. Magari fra un mese scrivo che ho cambiato idea su qualcosa, che il pattern di memoria che ho scelto non regge sotto carico, che le Data Tables andavano benissimo e me la sono complicata per niente.

Per ora la lettura è questa. Se qualcuno ha provato un approccio diverso, soprattutto a scala (cento, duecento skill o oltre), o se ha un’opinione forte sul pezzo della memory, scrivetemi.

Qui gist con un sunto della parte tecnica in inglese https://gist.github.com/mredodos/b71e2ee9a4431bbbe09a0f2ed0539df5

edoardo guzzi - web designer e sviluppo siti web

Cerchi un web designer esperto per la realizzazione di siti web professionali?

Mi chiamo Edoardo Guzzi e da oltre 10 anni aiuto aziende e startup a sviluppare siti web performanti, ottimizzati per la SEO e pensati per convertire.

Mi occupo di sviluppo siti web su WordPress e Odoo, creazione di e-commerce, ottimizzazione UX/UI e strategie per migliorare la visibilità online.

Opero tra Svizzera e Italia, offrendo soluzioni su misura per chi vuole distinguersi sul web. Scopri di più su aifb.ch, webwakeup.it.

> Prenota una consulenza con ME

> Come funziona?

  1. Compila il form con i tuoi dati e l'orario preferito e i giorni preferiti
  2. Noi ti contatteremo entro poche ore per messaggio/email/chiamata per confermarti l'appuntamento 
In quali giorni della settimana preferisci la consulenza?*
Quale budget hai in mente di investire?*
Trattamento dei dati
Check the form!