Trackily Docs

Tier System (Tier-1 vs Tier-2)

Trackily classifie tous ses tools MCP en Tier-1 (auto-execute, reads + writes "safe") ou Tier-2 (preview + confirm_token requis avant exécution). C'est la deuxième couche de défense après les scopes, pensée pour rendre un LLM hallucinant inoffensif sur les opérations destructives ou financières.

Concept

Les LLM hallucinent. C'est un fait. Si tu donnes à un agent le pouvoir d'appeler delete_campaign directement, tu te réveilles un matin avec 40 campagnes supprimées parce que l'agent a "interprété" une question d'analyse comme une instruction de cleanup. Pas glorieux.

Réponse de Trackily : un contrat two-step pour les opérations critiques.

                      TIER-2 CONTRACT
                      ───────────────

      First call                       Second call
      ──────────                      ───────────
      ┌──────────────────┐            ┌──────────────────┐
      │  tool: foo       │            │  tool: foo       │
      │  args: { ... }   │            │  args: { ... }   │
      │  ────────        │            │  + confirm_token │
      │  no token        │            │  ────────        │
      └────────┬─────────┘            └────────┬─────────┘
               │                               │
               ▼                               ▼
      ┌──────────────────┐            ┌──────────────────┐
      │ Status:          │            │ Validate token   │
      │ confirmation_    │            │ + idempotency    │
      │ required         │            │                  │
      │                  │            │   then           │
      │ + preview        │            │                  │
      │ + confirm_token  │            │ EXECUTE SQL      │
      │ (TTL 5min)       │            │                  │
      └──────────────────┘            └──────────────────┘

Concrètement :

  1. First call — l'agent appelle delete_campaign({ id: 42 }). Le tool ne mute RIEN. Il calcule un preview ("Will delete campaign #42 'Black Friday FR' — 12 active flows, 4523 lifetime clicks, $1247 lifetime revenue"), génère un confirm_token (TTL 5min, lié au token MCP + au nom du tool + au hash des params), et renvoie status: "confirmation_required".
  2. Affichage humain — le LLM montre le preview à l'opérateur, demande validation explicite.
  3. Second call — si l'opérateur valide, l'agent rappelle delete_campaign({ id: 42, confirm_token: "cfm_..." }). Le tool valide le token, exécute le SQL, retourne status: "ok".

Le LLM ne peut jamais générer un confirm_token valide tout seul — il vient forcément du premier round. Et le token est lié au hash des args : si le LLM change un arg entre les deux calls (de la id: 42 à id: 43), le token est rejeté params_changed.

Tier-1 — quoi qu'est dedans

Tous les reads sont Tier-1 (pas de mutation, rien à confirmer).

Les writes "safe" sont Tier-1 :

  • pause_campaign / resume_campaign (réversible, low-risk)
  • create_campaign, create_landing, create_email_list, etc. (création — facile à supprimer si erreur)
  • update_email_list, add_email_sequence_step (modifications routine)
  • enroll_email_subscriber, bind_landing_to_email_list (état facilement réversible)
  • generate_ai_landing etc. (génération AI — coûteux mais non-destructif)

Auto-execute : le LLM appelle le tool, le serveur exécute immédiatement, renvoie le résultat.

Tier-2 — quoi qu'est dedans

Toute opération qui est destructive, financière ou touche des credentials :

Catégorie Tools
Delete d'entités importantes delete_email_list, delete_collection, delete_product (Shopify), delete_cloaking_workflow, delete_email_conversion_trigger
Modification de payout / cost update_offer_payout, update_campaign_cost
Modification de budget ad update_source_campaign_budget, update_source_campaign_bid (impactent une vraie dépense €)
Ad networks pause/resume pause_source_campaign, resume_source_campaign (impacte la délivrance immédiate)
Refund / cancel refund_order, cancel_order, refund_native_order, cancel_native_order
Credentials create_email_smtp_server (password stocké chiffré)
Bulk operations bulk_update_product_prices, cross_platform_scale_plan

La règle de pouce : "Si le pire des cas est une perte de data, d'argent, ou une réputation cramée, c'est Tier-2."

Le contrat — détails

First call (sans confirm_token)

L'agent appelle le tool normalement, sans confirm_token dans les args.

Le handler du tool fait :

  1. Validation des params (types, valeurs requises)
  2. Calcul du preview en lisant l'état actuel de la DB
  3. tier2.issueToken({ tokenId, toolName, params, preview }) — génère un confirm_token et le stocke dans la map pendingConfirmations
  4. Retourne previewResponse({ tool, summary, preview, token })

Le payload de retour :

{
  "status": "confirmation_required",
  "tool": "update_offer_payout",
  "summary": "Will update payout of offer #12 'Sweepstakes FR Gold' from $4.50 to $6.20 (+37.8%).",
  "preview": {
    "offer_id": 12,
    "offer_name": "Sweepstakes FR Gold",
    "current_payout": "4.50",
    "new_payout": "6.20",
    "delta_pct": 37.8,
    "active_campaigns_using_this_offer": 4
  },
  "confirm_token": "cfm_a1b2c3d4e5f6g7h8i9j0k1l2",
  "confirm_instructions": "Review the preview above. If correct, call \"update_offer_payout\" again with the SAME arguments plus \"confirm_token\": \"cfm_a1b2c3d4e5f6g7h8i9j0k1l2\". Token expires in 5 minutes and is single-use."
}

Second call (avec confirm_token)

L'agent rappelle le tool avec les mêmes args + le confirm_token :

{
  "name": "update_offer_payout",
  "arguments": {
    "offer_id": 12,
    "new_payout": 6.20,
    "confirm_token": "cfm_a1b2c3d4e5f6g7h8i9j0k1l2"
  }
}

Le handler du tool fait :

  1. tier2.consumeToken({ confirm_token, tokenId, toolName, params }) — valide
  2. Si OK : exécute le SQL (UPDATE), récupère le résultat
  3. tier2.recordCompletion(confirm_token, result) — cache le résultat pour idempotent replay
  4. Retourne doneResponse({ tool, message, details })

Le payload de retour :

{
  "status": "ok",
  "tool": "update_offer_payout",
  "message": "Offer payout updated successfully",
  "offer": {
    "id": 12,
    "name": "Sweepstakes FR Gold",
    "payout": "6.20",
    "updated_at": "2026-05-18T14:33:21.812Z"
  }
}

Idempotent replay

Le cas réel : l'agent envoie le second call, le serveur exécute le SQL, mais la réponse HTTP se perd (timeout réseau, crash du client). L'agent retry le même call. Que se passe-t-il ?

Réponse : le même result est retourné sans re-exécuter l'opération. C'est l'idempotent replay, implémenté dans autopilot-tier2.js.

Le mécanisme :

  1. Après le commit, recordCompletion(confirm_token, result) stocke le résultat dans completedConfirmations (TTL 5min).
  2. Si le même confirm_token est rejoué dans les 5min, consumeToken détecte la cache hit et renvoie le résultat sans toucher à la DB.

Sécurité : la cache hit est gardée par 3 checks de métadonnées :

  • tokenId doit matcher — un attaquant qui vole un confirm_token mais a un autre MCP token est rejeté (wrong_owner)
  • toolName doit matcher — utiliser le token sur un autre tool est rejeté (wrong_tool)
  • paramsHash doit matcher — modifier les args entre les deux calls est rejeté (params_changed)

Ce dernier check matche le contrat Stripe's idempotency-key : si tu retry avec des args modifiés, tu reçois une erreur claire, pas un résultat stale silencieusement servi.

Reasons de rejet

consumeToken() peut retourner { ok: false, reason: ... } avec :

Reason Sens
unknown_or_used Token jamais émis, ou émis puis consommé sans recordCompletion
wrong_owner Token émis pour un autre MCP token (tokenId différent)
wrong_tool Token émis pour un autre tool (toolName différent)
params_changed Args modifiés entre preview et execute
expired TTL 5min dépassé

Le tool transforme ça en réponse user-friendly :

{
  "status": "rejected",
  "tool": "update_offer_payout",
  "error": "Arguments changed between the preview and the confirm call. Re-call without confirm_token to get a new preview.",
  "reason": "params_changed"
}

L'agent peut alors rappeler sans confirm_token pour récupérer un nouveau preview avec les nouveaux args.

Vie du token

       Issue                 Consume (1st time)         Cache hit (replay)
       ─────                 ───────────                ──────────────────
   ┌────────────┐         ┌────────────┐              ┌────────────┐
   │            │   5min  │            │    5min      │            │
   │  pending   │──────▶  │ in_flight  │ ──────────▶  │ completed  │
   │            │         │            │              │            │
   └────────────┘         └────────────┘              └────────────┘
        │                       │                            │
        │ janitor                │ janitor                    │ janitor
        │ (1min tick)            │ (1min tick)                │ (1min tick)
        ▼                       ▼                            ▼
   deleted after          deleted if recordCompletion    deleted after
   5min TTL              not called (= bug)              5min TTL

Le janitor tourne toutes les 60 secondes pour nettoyer les entries périmées dans les 3 maps. Sans janitor, la mémoire fuirait sur les longues sessions.

Implémentation pour un nouveau tool

Si tu ajoutes un tool destructif, suis ce template :

async function toolDeleteFoo({ params, ctx }) {
  const { id, confirm_token } = params;

  // Second call — execute
  if (confirm_token) {
    const validation = tier2.consumeToken({
      confirm_token,
      tokenId: ctx.token.id,
      toolName: 'delete_foo',
      params,
    });
    if (!validation.ok) {
      return tier2.rejectionResponse({ tool: 'delete_foo', reason: validation.reason });
    }
    // Cache hit — return cached result without re-running
    if (validation.completed) return validation.completed;

    // Real execute
    const result = await db.deleteFoo(id);
    const response = tier2.doneResponse({
      tool: 'delete_foo',
      message: `Foo ${id} deleted`,
      details: { foo: result },
    });
    tier2.recordCompletion(confirm_token, response);  // ← critical, after SQL commit
    return response;
  }

  // First call — preview
  const foo = await db.getFoo(id);
  if (!foo) return tier2.okText({ status: 'rejected', error: 'Foo not found' });

  const preview = {
    foo_id: id,
    foo_name: foo.name,
    related_things_count: foo.related_count,
  };
  const token = tier2.issueToken({
    tokenId: ctx.token.id,
    toolName: 'delete_foo',
    params,
    preview,
  });
  return tier2.previewResponse({
    tool: 'delete_foo',
    summary: `Will delete foo #${id} '${foo.name}' and ${foo.related_count} related things.`,
    preview,
    token,
  });
}

Comportement côté Claude Desktop

Claude Desktop affiche le preview brut au moment du first call, et demande à l'utilisateur une validation explicite avant de faire le second call. Tu vois quelque chose comme :

Trackily a proposé : update_offer_payout

Summary : Will update payout of offer #12 'Sweepstakes FR Gold' from $4.50 to $6.20 (+37.8%).

Preview : ...

[Approve] [Decline]

Si tu cliques Approve, Claude rappelle le tool avec le confirm_token. Si tu cliques Decline, rien ne se passe (le token finira par expirer dans 5min).

C'est cette double-vérification (LLM côté UX + serveur côté contrat) qui rend l'opération sûre.

Erreurs courantes

  • Tool destructif qui exécute directement sans preview — bug dans le handler : oubli du check if (!confirm_token). Toujours tester avec et sans confirm_token.
  • Token "expired" très rapidement — TTL est 5 min. Si l'opérateur réfléchit 10 min avant de valider, le token expire. Rappelle sans confirm_token pour un nouveau preview.
  • params_changed alors que rien n'a bougé visiblement — l'ordre des clés JSON compte côté hash. Utilise la même sérialisation entre les deux calls (le LLM le fait naturellement, sauf si tu reformat le payload).
  • wrong_tool — tu as appelé delete_offer avec un confirm_token issu de delete_landing. Token cross-tool est interdit by design.
  • Cache hit qui ne se produit pas après retry — le handler n'a pas appelé recordCompletion(confirm_token, result) après le SQL. Bug à fixer dans le handler.

Voir aussi

  • Concept — le protocole JSON-RPC sous-jacent
  • Tokens — les MCP tokens (à ne pas confondre avec les confirm_tokens)
  • Scopes — la couche de permissions complémentaire
  • Tools Reference — quels tools sont Tier-2