Claude Code CLI est excellent pour travailler en mode interactif. Mais dès qu'on veut l'intégrer dans un pipeline CI, une application web, ou un script d'automatisation, c'est le mur. Il n'y a pas d'API HTTP. Pas de moyen d'envoyer un job en arrière-plan, d'interroger son statut, ou de recevoir le résultat via webhook. Juste un binaire conçu pour un terminal.
C'est pour ça que j'ai écrit ClaudeGate : une passerelle HTTP en Go qui expose Claude Code CLI comme une API REST avec file d'attente asynchrone. Un binaire statique, pas de CGO, persistance SQLite, streaming SSE, webhooks avec protection SSRF. Le repo est sur GitHub.
Ce que fait ClaudeGate
On soumet un prompt via POST /api/v1/jobs. ClaudeGate le met en file,
l'exécute via le CLI Claude Code en sous-processus, et renvoie le résultat.
Pendant l'exécution, on peut streamer les chunks en temps réel via SSE,
ou simplement poller le statut avec GET /api/v1/jobs/{id}.
Quand le job est terminé, un webhook est déclenché vers l'URL configurée.
Les endpoints disponibles :
POST /api/v1/jobs— soumettre un jobGET /api/v1/jobs— lister avec pagination (limit,offset)GET /api/v1/jobs/{id}— polling du statutDELETE /api/v1/jobs/{id}— supprimerGET /api/v1/jobs/{id}/sse— stream SSE en temps réelPOST /api/v1/jobs/{id}/cancel— annuler un job en coursGET /api/v1/health— healthcheck (sans auth)GET /— playground web intégré
Lancer en 5 minutes :
git clone https://github.com/ohugonnot/claudegate.git
cd claudegate
cp .env.example .env
make build
./bin/claudegate
Le cycle de vie d'un job
Voici ce qui se passe entre le moment où la requête arrive et celui où le résultat est disponible :
POST /api/v1/jobs
│
▼
Validation + INSERT SQLite (status=queued)
│
▼
chan string ◄─── seul l'ID voyage dans le channel
│
▼
Worker goroutine (N workers en parallèle)
│
▼
exec.CommandContext(claude --output-format stream-json ...)
│
├──► Chunks "assistant" → SSE fan-out → clients abonnés
│
└──► Event "result" → UPDATE SQLite (status=completed) → webhook
Un détail de conception important : seul l'ID du job voyage dans le channel, pas le struct complet. C'est intentionnel — quand on reçoit l'ID côté worker, on relit le job depuis SQLite. Si entre-temps le job a été annulé, on le détecte immédiatement avant de lancer le sous-processus.
La file d'attente : channel + workers + backpressure
Le package internal/queue repose sur un chan string bufferisé.
L'enqueue est non-bloquant grâce à un select/default :
func (q *Queue) Enqueue(id string) error {
select {
case q.ch <- id:
return nil
default:
// Queue pleine — backpressure immédiate
return ErrQueueFull
}
}
Si le channel est plein, on retourne immédiatement une erreur au client plutôt que de bloquer. C'est la backpressure : le client sait qu'il doit réessayer plus tard, au lieu d'attendre indéfiniment.
Les workers sont lancés au démarrage de l'application. En Go 1.22+,
range sur un entier est natif :
for range q.cfg.Concurrency {
go q.runWorker(ctx)
}
Chaque worker lit le même channel. Pas besoin de dispatch explicite — le scheduler
Go gère la répartition. Pour annuler un job en cours, un registry
cancels map[string]context.CancelFunc permet d'appeler la fonction
d'annulation par ID depuis le handler HTTP POST /api/v1/jobs/{id}/cancel.
Le streaming SSE : fan-out sans bloquer
Le CLI Claude Code produit du JSON ligne par ligne avec
--output-format stream-json. Chaque ligne est soit un chunk
"assistant" (texte partiel), soit un event "result" (réponse finale complète).
Le worker parse chaque ligne et dispatch via le mécanisme de fan-out SSE.
L'architecture SSE dans internal/queue :
type SSEHub struct {
mu sync.Mutex
subs map[string][]chan SSEEvent // jobID → liste de channels clients
}
func (h *SSEHub) Publish(jobID string, event SSEEvent) {
h.mu.Lock()
defer h.mu.Unlock()
for _, ch := range h.subs[jobID] {
select {
case ch <- event:
default:
// Client trop lent — drop silencieux
}
}
}
Chaque client SSE connecté a son propre channel bufferisé (capacité 64).
L'envoi est non-bloquant : si le client ne consomme pas assez vite,
les events sont silencieusement abandonnés plutôt que de bloquer le worker.
Trois types d'events : status, chunk, result.
Cas particulier : si un client SSE se connecte sur un job déjà terminé, il reçoit immédiatement le résultat final sans s'abonner — on évite une subscription morte sur un job qui ne produira plus rien.
Webhook : délivrance async + protection SSRF
À la fin de chaque job, ClaudeGate peut envoyer un webhook vers une URL configurée. Le payload :
{
"job_id": "01HV...",
"status": "completed",
"result": "...",
"error": ""
}
L'envoi est fire-and-forget dans une goroutine dédiée, avec 3 tentatives en backoff exponentiel (1s → 2s → 4s) et un timeout de 30s par requête.
Ce qui m'a pris le plus de temps : la protection SSRF. Une URL de webhook
mal validée peut permettre à n'importe qui de faire scanner votre réseau
interne en soumettant des jobs avec des URLs du type http://192.168.1.1/admin.
La protection dans internal/webhook :
func validateWebhookURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
// Résolution DNS
addrs, err := net.LookupHost(u.Hostname())
if err != nil {
return fmt.Errorf("DNS lookup failed: %w", err)
}
for _, addr := range addrs {
ip := net.ParseIP(addr)
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
return fmt.Errorf("SSRF: private/loopback IP rejected: %s", addr)
}
}
return nil
}
On résout le DNS avant l'envoi, et on rejette toute IP privée (RFC 1918), loopback, ou link-local. Ça couvre les attaques classiques SSRF via rebinding DNS partiel.
Sécurité : auth constante-time et system prompt garde-fou
L'authentification repose sur un header X-API-Key, comparé
avec crypto/subtle.ConstantTimeCompare pour éviter les timing attacks.
Plusieurs clés sont supportées en parallèle pour permettre la rotation sans coupure.
Chaque job reçoit automatiquement un system prompt de sécurité prepend :
il interdit au modèle d'exécuter des commandes shell, d'accéder au filesystem,
ou de faire des appels réseau non demandés. Désactivable avec
CLAUDEGATE_UNSAFE_NO_SECURITY_PROMPT=true si vous savez ce que vous faites.
Le body des requêtes est limité à 1MB pour éviter les abus évidents.
SQLite : persistance simple, crash recovery, binaire statique
SQLite s'est imposé naturellement : pas de dépendance externe, binaire distribué en un seul fichier, suffisant pour le volume attendu.
J'utilise modernc.org/sqlite — une implémentation pure Go
de SQLite, sans CGO. Ça permet de compiler un binaire statique,
ce qui simplifie drastiquement le déploiement (copier un binaire, c'est tout).
Deux aspects du code SQLite méritent d'être notés :
WAL mode + busy_timeout : SQLite utilise des locks au niveau fichier.
Sans configuration, quand un worker détient le lock pour écrire un résultat, tout autre
accès concurrent reçoit immédiatement SQLITE_BUSY — une erreur, pas une attente.
Worker 1: UPDATE jobs SET status='completed'... ← lock acquis
Worker 2: UPDATE jobs SET status='processing'... ← SQLITE_BUSY immédiat, erreur
API: SELECT * FROM jobs... ← SQLITE_BUSY immédiat, erreur
busy_timeout = 10000 change ce comportement : SQLite attend jusqu'à
10 secondes en réessayant avant de retourner l'erreur. Pour la grande majorité des
contentions (quelques millisecondes entre deux workers), ça résout silencieusement
le problème.
WAL seul ne suffit pas. WAL résout les lectures concurrentes avec les écritures —
readers et writers ne se bloquent plus mutuellement. Mais deux writers simultanés
restent exclusifs. Avec CLAUDEGATE_CONCURRENCY > 1, deux workers peuvent
terminer simultanément et tenter un UPDATE en même temps.
WAL → lecteurs et écrivains ne se bloquent plus mutuellement
timeout → deux écrivains simultanés : le second attend au lieu d'échouer
WAL mode : activé au démarrage pour des lectures concurrentes sans bloquer les écritures. Indispensable avec N workers qui mettent à jour des jobs en parallèle pendant que l'API répond à des requêtes GET.
Crash recovery : au démarrage, on cherche tous les jobs
avec status processing (ceux qui tournaient au moment d'un crash)
et on les remet en queued pour qu'ils soient ré-exécutés :
func (s *SQLiteStore) RecoverStaleJobs(ctx context.Context) (int, error) {
result, err := s.db.ExecContext(ctx,
`UPDATE jobs SET status = 'queued' WHERE status = 'processing'`)
if err != nil {
return 0, fmt.Errorf("crash recovery: %w", err)
}
n, _ := result.RowsAffected()
return int(n), nil
}
La migration de schéma est idempotente : les ALTER TABLE ADD COLUMN
sont lancés au démarrage avec l'erreur "column already exists" silencieusement ignorée.
Pas de système de migration numérotée — trop lourd pour un projet de cette taille.
Une goroutine TTL tourne en arrière-plan pour supprimer les vieux jobs selon la rétention configurée. Pas besoin de cron externe.
Patterns Go modernes utilisés
Go 1.22 a introduit deux fonctionnalités qui simplifient le code de façon notable.
Routing stdlib avec méthode HTTP : plus besoin de router tiers
pour des cas simples. La syntaxe "METHOD /path/{param}"
est maintenant native dans net/http :
mux.HandleFunc("GET /api/v1/jobs/{id}/sse", h.StreamSSE)
mux.HandleFunc("POST /api/v1/jobs/{id}/cancel", h.CancelJob)
mux.HandleFunc("DELETE /api/v1/jobs/{id}", h.DeleteJob)
Range over integer : for range N { go worker() }
remplace l'ancienne boucle for i := 0; i < N; i++ quand on n'a
pas besoin de l'index.
Interface Store pour la testabilité : le package internal/job
définit une interface Store que l'implémentation SQLite satisfait.
Les handlers reçoivent l'interface, pas le type concret — ce qui permet
de brancher un store in-memory dans les tests sans toucher aux handlers.
statusResponseWriter : un wrapper sur http.ResponseWriter
qui capture le status code pour les logs structurés. Pattern classique mais
souvent oublié dans les projets qui ne veulent pas dépendre d'un framework :
type statusResponseWriter struct {
http.ResponseWriter
status int
}
func (w *statusResponseWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
// Dans le middleware de logging :
wrapped := &statusResponseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(wrapped, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.status,
"duration_ms", time.Since(start).Milliseconds())
Playground intégré : le fichier HTML du playground est embarqué
directement dans le binaire via //go:embed. Pas de static/
à déployer à côté du binaire :
//go:embed static/index.html
var playgroundHTML []byte
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(playgroundHTML)
})
Conclusion
ClaudeGate fait une chose simple : prendre un outil CLI et le rendre accessible via HTTP. Ce qui m'a le plus occupé n'est pas la partie "passer un prompt au CLI" — c'est tout autour : la backpressure, le crash recovery, la protection SSRF, le fan-out SSE sans bloquer les workers.
Go était le bon choix pour ce projet. Un seul binaire statique, la stdlib
qui couvre 90% des besoins HTTP, les goroutines pour gérer naturellement
les workers et le fan-out SSE, et modernc.org/sqlite pour
éviter CGO. Le tout en moins de 2000 lignes de code.
Le repo est disponible sur github.com/ohugonnot/claudegate. Contributions bienvenues.