package main import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "text/template" "time" ) const ( headerVersion = "2023-06-01" headerBeta = "experimental-cc-routine-2026-04-01" ) // FireClient POSTs to the Anthropic /fire endpoint with the headers and body // shape required by the Claude Code routines beta. Each call creates a new // session; the daemon never retries on failure. type FireClient struct { URL string Token string HTTP *http.Client Template *template.Template TZ *time.Location Log *slog.Logger NowFunc func() time.Time // overridable for tests; defaults to time.Now } type fireRequest struct { Text string `json:"text"` } type fireResponse struct { Type string `json:"type"` ClaudeCodeSessionID string `json:"claude_code_session_id"` ClaudeCodeSessionURL string `json:"claude_code_session_url"` } // Fire renders the text template, POSTs to the routine /fire endpoint, and // logs the outcome. Returns nil on transport-level success regardless of HTTP // status — non-2xx is logged at error level so the daemon stays up. // Returns a non-nil error only for unrecoverable client-side problems // (template render failure or request build failure). func (c *FireClient) Fire(ctx context.Context, cronExpr string) error { now := c.now() text, err := c.renderText(now, cronExpr) if err != nil { return fmt.Errorf("render text: %w", err) } body, err := json.Marshal(fireRequest{Text: text}) if err != nil { return fmt.Errorf("marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(body)) if err != nil { return fmt.Errorf("build request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.Token) req.Header.Set("anthropic-version", headerVersion) req.Header.Set("anthropic-beta", headerBeta) req.Header.Set("Content-Type", "application/json") resp, err := c.HTTP.Do(req) if err != nil { c.Log.Error("fire request failed", "cron", cronExpr, "err", err.Error()) return nil } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { c.Log.Error("fire non-2xx response", "cron", cronExpr, "status", resp.StatusCode, "body", string(respBody)) return nil } var fr fireResponse if err := json.Unmarshal(respBody, &fr); err != nil { c.Log.Warn("fire 2xx but body unparseable", "cron", cronExpr, "status", resp.StatusCode, "body", string(respBody)) return nil } c.Log.Info("fire ok", "cron", cronExpr, "session_url", fr.ClaudeCodeSessionURL, "session_id", fr.ClaudeCodeSessionID) return nil } func (c *FireClient) now() time.Time { if c.NowFunc != nil { return c.NowFunc() } return time.Now() } func (c *FireClient) renderText(now time.Time, cronExpr string) (string, error) { data := map[string]any{ "Now": now, "LocalTime": now.In(c.TZ).Format("2006-01-02 15:04 MST"), "Cron": cronExpr, } var buf bytes.Buffer if err := c.Template.Execute(&buf, data); err != nil { return "", err } return buf.String(), nil }