Tâches avancées

Choisir l’agent qui exécute une tâche, son modèle et ses réglages, piloter les tâches par l’API, et les raisons derrière les parties du modèle qui paraissent étranges.

Tout ce qui est dans Tâches fonctionne sans lire cette page. Celle-ci sert quand vous voulez choisir entre Claude Code et Codex, tout piloter depuis un script, ou comprendre pourquoi une tâche refuse de se clore d’elle-même.

L’agent d’une tâche est une app — aujourd’hui Claude Code et Codex, chacune pilotée par le SDK officiel de son éditeur. Votre organisation les installe sous Settings → Integrations ; une nouvelle organisation arrive avec Claude Code installé.

  • Sur le projet (Edit project → Agents), activez les apps sous lesquelles ses tâches peuvent tourner. La première est celle par défaut ; Make default en promeut une autre. Chaque app activée met son SDK dans l’image du projet, donc enregistrer la reconstruit.
  • Sur chaque tâche, la puce Harness de la zone de saisie — la première, puisque tout ce qui suit en dépend — choisit l’une des apps du projet. Elle est fixée pour toute la conversation, relances comprises.

Chaque app lit son propre identifiant dans vos secrets : ANTHROPIC_API_KEY pour Claude Code, OPENAI_API_KEY pour Codex.

L’app dit ce qu’on peut choisir sous elle — ses modèles, et ses propres réglages :

AppRéglages
Claude CodeEffort — low, medium, high, extra high, max
CodexReasoning — de low à ultra, limité à ce que le modèle choisi prend en charge, avec la valeur par défaut de ce modèle affichée ; Web search — off, cached, live

Côté API, le catalogue est GET /api/orgs/{orgId}/integrations/agents, et une tâche prend agentAppId, model et agentOptions ({ "effort": "high" }). Un réglage que l’app n’a pas, ou une valeur que le modèle n’accepte pas, est refusé à la création de la tâche.

Le premier tour que reçoit le harnais est votre prompt plus un bloc de deux lignes, et la séparation entre ces deux lignes est tout le principe :

LigneNatureConfigurable
Spunto context: you are running in a disposable worker, on branch …, already checked out.un fait que l’agent ne peut pas déduire depuis l’intérieur de son conteneurnon
When the work is done: commit on this branch and push it.un parti pris sur ce que « terminé » veut direoui — Settings → Agents & tasks, par organisation
(rien par défaut)ce que seul ce dépôt a besoin de direoui — Agent instructions dans les réglages des tâches du projet, ajouté après celle de l’organisation

La consigne d’une organisation remplace celle par défaut au lieu de s’y ajouter. S’y ajouter laisserait deux phrases qui se contredisent dès que quelqu’un veut un autre fonctionnement — et celle qu’il n’a pas écrite viendrait en premier.

La consigne de l’organisation est une règle maison — ce que « terminé » veut dire n’a aucune raison de changer entre deux dépôts d’une même équipe. Ce qui change (quelle suite de tests lancer, quel dossier ne pas toucher) va dans les Agent instructions propres au projet, qui sont ajoutées après celle de l’organisation plutôt que de la remplacer : un projet peut compléter une règle maison, pas s’en dispenser en silence.

Elle est lue au démarrage d’une tâche, jamais recopiée sur la ligne de la tâche. Une tâche en cours garde la consigne avec laquelle elle a commencé — la relire à chaque tour réécrirait en silence l’historique des sessions déjà lancées.

Note

Les skills sont des fichiers, pas du texte, donc ils ne passent pas par cette consigne : ils viennent des sources de skills de l’organisation, rafraîchis avant chaque tâche. Les définitions de sous-agents et settings.json arrivent toujours à l’agent par le dépôt cloné ou les dotfiles du membre.

Chaque commande que Spunto lance pour une tâche — la session, accept, drop, reset — reçoit celles-ci :

SPUNTO_TASK_ID              SPUNTO_TASK_BRANCH        SPUNTO_TASK_STATE
SPUNTO_TASK_TITLE           SPUNTO_TASK_BASE_BRANCH   SPUNTO_TASK_SESSION_STATUS
                                                      SPUNTO_TASK_MODEL (when resolved)

Une tâche qui porte des liens ou des métadonnées les reçoit aussi en JSON, dans SPUNTO_TASK_LINKS et SPUNTO_TASK_METADATA.

Une relance en reçoit une de plus, SPUNTO_TASK_SESSION_ID — l’identifiant de session propre au harnais, qui est ce qui permet de reprendre une conversation.

Les secrets de votre projet sont là aussi, résolus exactement comme pour une sandbox que vous ouvrez vous-même — une commande d’acceptation peut donc être gh pr merge sans que vous colliez un token dans les réglages du projet.

Note

Les variables SPUNTO_TASK_* sont appliquées en dernier. Un secret qui porte l’un de leurs noms ne peut pas l’écraser — elles sont le contrat sur lequel reposent vos scripts.

Default model sur le projet et Model dans la zone de saisie listent les modèles de l’app du harnais. Le modèle par défaut du projet appartient à son harnais par défaut ; une tâche sous une autre app démarre sur le modèle par défaut de cette app. Le champ reste du texte libre en dessous : un modèle que le catalogue ne liste pas encore passe quand même.

Le modèle d’une tâche est résolu une fois, à la création, et reste fixé pour toute la conversation, y compris chaque tour de relance — au lieu d’être relu sur le projet à chaque tour. Il est aussi exporté dans SPUNTO_TASK_MODEL.

Une session d’agent dure de quelques minutes à quelques heures. Sans flux lisible par une machine, Spunto n’aurait qu’un seul fait à offrir pendant tout ce temps — « ça tourne » — avec la sortie arrivant d’un bloc à la fin. Chaque app d’agent diffuse sa session, et Spunto la lit au fil de l’eau :

DialecteAppCe que vous obtenez
claude-runnerClaude CodeLes messages de l’Agent SDK. Interactif : la session reste vivante entre deux tours, vous pouvez donc la lire en direct, lui parler pendant qu’elle travaille, et interrompre un tour sans la tuer.
codex-runnerCodexLes événements du SDK Codex. Interactif, tour par tour : un message envoyé pendant un tour démarre le suivant.

Il n’y a rien à configurer : le dialecte vient avec l’app. Les événements d’une tâche indiquent dans lequel ils sont lus (protocol).

Ce qui est stocké n’a jamais la forme d’un harnais :

session.started   session.title   message      thinking
tool.call         tool.result     plan         usage      session.ended

…plus raw pour tout ce qu’un adaptateur n’a pas su placer.

Il faut un dénominateur commun : on ne peut pas afficher Codex, Gemini CLI et un script shell à vous à travers le format de messages d’un seul éditeur. Les adaptateurs traduisent à la frontière, et raw garantit que rien ne se perd en silence — un événement inconnu est en soi une information sur le harnais.

La ligne d’origine du harnais est conservée à côté de chaque événement. Normaliser sans elle serait un aller simple : un adaptateur qui associe mal un champ, ou qui n’en connaît pas encore un nouveau, détruirait l’original à l’écriture. Avec elle, type et payload sont une projection — recalculable depuis la ligne qui les a produits. Elle coûte cher au regard de ce qu’elle explique, donc elle n’est jamais renvoyée sauf si vous la demandez (?source=true).

C’est aussi pourquoi il existe une troisième option, Spunto events (JSONL) : imprimez un de ces objets par ligne depuis votre propre harnais et il s’affiche exactement de la même façon.

{"type":"message","payload":{"role":"assistant","text":"Found it."}}
{"type":"tool.call","payload":{"callId":"1","name":"Edit","input":"{\"file_path\":\"src/a.ts\"}"}}
{"type":"tool.result","payload":{"callId":"1","output":"1 replacement.","isError":false}}

Note

Imprimez {"type":"session.title","title":"…"} et votre tâche est nommée de la même façon que les conversations de Claude Code.

curl -H "Authorization: Bearer spk_..." \
  "https://spunto.net/api/orgs/{orgId}/projects/{projectId}/tasks/{taskId}/events?since=0"

seq est dense et monotone par tâche — renvoyez le plus élevé que vous avez comme since et vous ne relisez jamais ce que vous avez déjà. Ajoutez &source=true pour les lignes du harnais.

Le panneau Session affiche le contexte, les tokens et le coût de toute la tâche, pas de la page d’événements que vous êtes en train de lire. Trois règles derrière ces chiffres :

  • Les tokens sont additionnés par nous, sur les événements de chaque appel. Un tour en cours n’a pas encore produit de ligne de résultat finale — un récapitulatif qui l’attendrait n’afficherait rien précisément pendant que quelqu’un regarde.
  • Le total courant d’un harnais n’est jamais additionné. En mode interactif, une ligne de résultat arrive à chaque tour et reprend toute la session. Les additionner facturerait le premier tour une fois par tour suivant.
  • Le montant vient du harnais, jamais de nous. Chiffrer un appel exige une grille tarifaire ; une plateforme qui en inventerait une afficherait un chiffre qu’elle ne peut pas défendre.

La jauge de contexte compare le prompt du dernier appel à la fenêtre avec laquelle la session s’est ouverte. Rien dans le flux n’indique cette fenêtre, elle est donc résolue depuis un catalogue d’identifiants de modèles, ou depuis la Models API quand les secrets de la tâche contiennent un identifiant Anthropic. Un modèle qu’aucune des deux sources ne connaît n’a pas de pourcentage — une jauge sur une fenêtre devinée est pire que pas de jauge, et le nombre de tokens reste juste dans tous les cas.

POST .../tasks/{taskId}/messages    { prompt, files? }   → 202
POST .../tasks/{taskId}/interrupt                        → 202

La session de chaque app d’agent tient un stdin ouvert dans la sandbox, donc un message y est écrit directement — pas de job, pas de reprise, pas de redémarrage. C’est la session qui détient déjà le contexte qui le reçoit. Interrupt envoie une requête de contrôle : le harnais termine le tour en cours et reste vivant, donc le message suivant est un tour ordinaire. C’est toute la différence avec Drop, qui tue le processus et fait échouer la tâche.

Sur les flux non interactifs, il n’y a pas de stdin ouvert : une réponse n’est acceptée que tant que la tâche est in-review — c’est-à-dire quand elle vous attend de toute façon — et elle fonctionne en reprenant le harnais avec son identifiant de session. Spunto ne connaît cet identifiant que parce qu’il l’a lu dans le flux, et c’est pourquoi répondre exige un flux de session : l’alternative serait une nouvelle session sans aucun souvenir du tour qu’elle est censée prolonger.

Un harnais qui reprend autrement a sa propre Follow-up command.

Note

Défaut connu : un tour interrompu apparaît comme un session.ended en échec. C’est vrai — le tour ne s’est pas terminé — mais la timeline ne distingue pas encore « interrompu » de « planté ».

Les fichiers circulent dans les deux sens, de tous types. Vous les joignez depuis le presse-papier (là où vit une capture d’écran), en les déposant n’importe où sur la zone de saisie, ou avec le trombone au bord gauche de la zone d’écriture. Les fichiers de l’agent lui-même — une capture prise par un outil, un PDF qu’il a lu — sont extraits du flux et stockés, la ligne gardant leur identifiant à leur place.

Ils arrivent à l’agent par chemin, jamais par contenu : chaque fichier est écrit dans $HOME/.spunto/attachments/<task>/<id>/ dans la sandbox, et le texte envoyé au harnais gagne une ligne qui donne ces chemins. Un harnais n’a donc besoin de rien savoir de plus qu’« ouvrir un fichier dont on m’a donné le chemin » — ce qui marche pour tous les agents, pour un PNG comme pour un CSV de 4 Mo, et le fichier est toujours là pour être rouvert plus tard.

Limites : 10 Mo par fichier, 10 fichiers et 25 Mo par tour, dédoublonnés par tâche sur leur contenu, supprimés avec la tâche. Les images sont réduites à 1 568 px avant l’envoi — la taille à laquelle le modèle les redimensionne de toute façon —, si bien qu’une capture Retina pèse moins de 200 Ko. Tout le reste part tel quel.

Attention

Seuls les PNG, JPEG, WebP et GIF sont servis avec leur propre type de contenu. Tout le reste se télécharge au lieu de s’afficher, quel que soit le type déclaré à l’envoi — servir du HTML ou du SVG fourni par un utilisateur depuis l’origine même du dashboard serait une XSS stockée contre toute l’organisation. Un SVG est bien accepté ; il est simplement téléchargé au lieu d’être exécuté.

Chaque commande que Spunto a lancée pour la tâche, avec sa sortie — la mise en place de la branche, la session d’agent, la commande d’acceptation ou d’abandon. Chacune montre le shell exact qui a tourné, où, combien de temps, et ce qu’il a affiché. Celles en échec et celles encore en cours sont dépliées.

curl -H "Authorization: Bearer spk_..." \
  https://spunto.net/api/orgs/{orgId}/projects/{projectId}/tasks/{taskId}/commands

De la plus ancienne à la plus récente — l’ordre dans lequel les choses se sont passées.

Note

Les commandes que vous avez lancées dans la même sandbox sont volontairement absentes. Elles appartiennent à la sandbox, pas à la tâche.

Spunto a un temps permis à un projet de brancher une status command sur sa forge : un script lancé dans la machine de la tâche toutes les quelques secondes, dont la sortie pouvait faire avancer une tâche — jusqu’à done.

Elle a disparu, et la raison mérite d’être dite, parce que ça ressemble à une fonctionnalité perdue. Elle permettait à votre forge d’agir sur vos machines. Mergiez-vous une pull request depuis votre téléphone, et un conteneur que vous étiez encore en train de relire était mis de côté, une tâche que vous n’aviez jamais regardée se clôturait d’elle-même, et la machine était confiée à la tâche suivante — écrasant l’arbre de travail depuis lequel vous aviez mergé. Le déclencheur était ailleurs, la conséquence était ici, et rien à l’écran ne reliait les deux.

L’état d’une tâche vient donc de ce que Spunto a lancé et de ce sur quoi vous avez cliqué, et de rien d’autre :

La sessionLa tâche
tournerunning
s’est mal terminéefailed
s’est terminée proprementin-review — et elle attend

C’est le même raisonnement qui fait que le panneau de pull request est en lecture seule, et qu’il n’a pas de bouton Merge. Lire n’a pas ce pouvoir ; écrire l’aurait.

Votre forge reste l’endroit où le travail atterrit, et votre commande d’acceptation reste l’endroit où se fait un merge — la différence, c’est que c’est vous qui la déclenchez, depuis la tâche que vous regardez. Si une pull request a été mergée ailleurs, appuyer sur Accept est la façon de dire à Spunto que le travail est fini : la commande de merge est idempotente sur une branche déjà mergée, ou vous la laissez vide et Accept clôt simplement la tâche.

Il n’y a pas non plus d’état « en attente de votre réponse ». Il ne peut pas encore être détecté honnêtement, et un état qui ment est pire qu’un état qui manque.

Note

L’état d’une tâche active est recalculé quand quelqu’un le lit, et sinon toutes les 30 secondes en arrière-plan. C’est ce plancher qui permet à une tâche d’atteindre la relecture — et donc de mettre son conteneur de côté avec Stop it — même dashboard fermé.

Le panneau de pull request est servi par une capability optionnelle — git.pullRequestForBranch. Un projet dont les dépôts sont de simples URL de clone n’a personne à interroger, donc la section est tout simplement absente plutôt que vide.

Le contrat est volontairement mince : draft | open | merged | closed, plus un lien, un libellé, un titre, des checks, un verdict de revue et un nombre de commentaires. Aucun champ propre à une forge — chacun ajouté ici est un champ que toutes les forges devraient fournir.

Tout ce qui suit state est nullable, et ça a un sens. checks: null veut dire cette forge ne les rapporte pas, ce qui n’est pas rien n’a échoué — un dépôt sans CI n’a rien réussi non plus, et le panneau ne lui dessinera pas de coche verte.

Deux détails déjà réglés côté GitHub : les check runs (Actions, toute CI qui est une App) et les anciens commit statuses (Buildkite, un webhook Jenkins) sont deux moitiés qu’il faut lire ensemble, et seule la dernière revue de chaque personne compte — les additionner toutes laisserait quelqu’un qui a demandé des changements puis approuvé coincé à « changes requested » pour toujours.

Une branche poussée sur un fork n’est pas trouvée, et c’est dit plutôt que contourné : la trouver obligerait à balayer toutes les pull requests ouvertes du dépôt à chaque interrogation.

Le navigateur interroge lentement (30 s, et une seule fois pour une tâche terminée), et l’API met la réponse en cache quelques secondes — chaque réponse coûte plusieurs appels à la forge, sur un quota que toute l’organisation partage. C’est l’inverse du diff, qui n’est jamais mis en cache, et les deux ont raison : un diff décrit une machine à laquelle vous pouvez encore parler, une pull request décrit un objet distant qui bouge d’une minute à l’autre.

# Accept command
gh pr merge "$SPUNTO_TASK_BRANCH" --squash --delete-branch
 
# Drop command
gh pr close "$SPUNTO_TASK_BRANCH" --delete-branch

Elles tournent dans la sandbox de la tâche elle-même — redémarrée d’abord si le mode de relecture l’avait mise de côté —, elles peuvent donc toucher au checkout aussi bien qu’à la forge. Accept exige un code de sortie 0 ; un merge qui échoue laisse la tâche en relecture.

Rien dans le cycle de vie d’une tâche n’appelle GitHub. Le seul endroit où une forge apparaît est le clone qui remplit la sandbox — le vrai prérequis est donc qu’un dépôt git existe dans le workspace, pas qu’un compte soit connecté. Un projet peut construire le sien, ce qui est le moyen le plus court d’essayer le travail délégué sans connecter de forge :

# postCreate — a repository and its origin, inside the sandbox
cd /workspace
if [ ! -d .git ]; then
  git init -q -b main .
  echo ".sandbox-origin.git/" > .gitignore
  git config user.email you@example.com && git config user.name "Sandbox"
  git add -A && git commit -qm "initial"
  git init -q --bare -b main .sandbox-origin.git
  git remote add origin /workspace/.sandbox-origin.git
  git push -q -u origin main
fi

Tout le cycle tourne alors sans changement — sandbox, branche, session, relecture, diff, Accept — sans compte de forge nulle part. L’agent a toujours besoin de son propre identifiant (ANTHROPIC_API_KEY pour Claude Code).

Astuce

Gardez l’identité git locale au dépôt et l’origin dans /workspace, comme ci-dessus. Les deux vivent alors dans /workspace, qu’une sandbox arrêtée conserve, et elle retrouve ses branches quand elle redémarre.

curl -H "Authorization: Bearer spk_..." \
  https://spunto.net/api/orgs/{orgId}/projects/{projectId}/tasks/{taskId}
{
  "id": "k3f9x2...",
  "title": "The Ship button overflows its card on mobile",
  "state": "in-review",
  "branch": "task/the-ship-button-overflows-its-card-a1b2c3",
  "baseBranch": "main",
  "model": "claude-opus-5",
  "machineKind": "sandbox",
  "sandboxId": "sbx_abc123",
  "commandId": "cmd_def456",
  "outcomeUrl": "https://github.com/acme/app/pull/262",
  "outcomeLabel": "#262",
  "links": [{ "label": "SPU-142", "url": "https://cms.example.com/items/spu-142" }],
  "metadata": { "cmsItemId": "itm_8f2k1" },
  "startedAt": "2026-08-30T09:14:02.000Z",
  "completedAt": null
}

outcomeUrl et outcomeLabel sont ce que le travail a produit, quel qu’il soit — une pull request, une merge request, un déploiement de prévisualisation. Rien dans le modèle n’est propre à une forge ; le critère pour une colonne typée, c’est le produit l’affiche, et le produit affiche un lien et son libellé.

Ce qui crée une tâche — vous, un script, une automatisation qui réagit à un ticket — peut dire d’où elle vient. Aucun des deux champs n’est jamais écrit par Spunto.

curl -X POST -H "Authorization: Bearer spk_..." -H "Content-Type: application/json" \
  https://spunto.net/api/orgs/{orgId}/projects/{projectId}/tasks \
  -d '{
    "prompt": "…",
    "links": [{ "label": "SPU-142", "url": "https://cms.example.com/items/spu-142" }],
    "metadata": { "cmsItemId": "itm_8f2k1", "source": "backlog" }
  }'
  • links est pour les humains : affichés en haut des détails de la tâche, http(s) uniquement, jusqu’à 10.
  • metadata est pour les machines : jusqu’à 50 paires de chaînes (clés de 1 à 40 caractères parmi A–Z a–z 0–9 _ . -, valeurs jusqu’à 500), affichées repliées et jamais interprétées. Retrouvez une tâche avec GET /api/orgs/{orgId}/tasks?metadata[cmsItemId]=itm_8f2k1 — tâches archivées comprises sauf si vous passez archived.

PATCH .../tasks/{taskId} modifie les deux par la suite : links est remplacé en entier, metadata est fusionné clé par clé (null ou "" supprime une clé).

commandId est la session d’agent, une commande lancée dans la sandbox de la tâche : sa sortie est là pendant qu’elle tourne et après qu’elle a fini. Comme la session vit dans la sandbox et non dans l’API, elle survit entièrement aux redémarrages de notre côté.

POST   .../projects/{projectId}/tasks                       delegate { prompt, title?, baseBranch?, model?, sandboxSize?, fromSnapshot?, files?, links?, metadata? }
GET    /api/orgs/{orgId}/tasks                              every task of the org (?state=&q=&projectId=&metadata[key]=&limit=&offset=)
PATCH  .../tasks/{taskId}                                   rename, replace links, merge metadata { title?, links?, metadata? }
GET    .../projects/{projectId}/tasks                       one project's, most recently active first
GET    .../tasks/{taskId}                                   detail
GET    .../tasks/{taskId}/commands                          what the platform ran, oldest first
GET    .../tasks/{taskId}/events                            the session, event by event (?since=&source=)
GET    .../tasks/{taskId}/diff                              what the branch changes, read with git in the sandbox
GET    .../tasks/{taskId}/diff/patch                        one file's patch (?path=&repo=&oldPath=&untracked=)
GET    .../tasks/{taskId}/pull-request                      what the forge says, if it can be asked
GET    .../tasks/{taskId}/attachments/{attachmentId}        an attachment's bytes
POST   .../tasks/{taskId}/messages                          talk to the session (202) — a done/failed task too, while its sandbox exists
POST   .../tasks/{taskId}/retry                             start over a failed task whose session never started (202)
POST   .../tasks/{taskId}/interrupt                         end the current turn, keep the session (202)
POST   .../tasks/{taskId}/validate                          Accept (202)
POST   .../tasks/{taskId}/cancel                            Drop (202)

Le détail porte ce dont un client a besoin pour proposer l’un ou l’autre : canReopen (une tâche terminée dont la sandbox existe encore — un message la remet en relecture et reprend la session), reopenBlocker (pourquoi pas, quand c’est impossible) et canRetry ; la timeline marque une tâche rouverte d’un événement session.reopened, et le checkout est laissé sur la branche où il se trouve.

Tout ce qui fait un vrai travail tourne comme un job de fond durable — task.run, task.settle, task.validate, task.cancel. Rien de tout cela ne meurt avec un redéploiement de l’API, et c’est pourquoi les POST qui agissent sur une tâche existante répondent 202 au lieu d’attendre. Déléguer répond tout de suite avec la tâche en queued.