[object Object]

← back to Cli Printing Press

feat(cli): async-job detection, --wait flag, jobs command

1c172850d5882ea89cf00d3e74090c47cdcab6de · 2026-04-17 22:58:58 -0400 · Matt Van Horn

Detect endpoints that submit long-running work (job-id-shaped response
field + sibling status endpoint) and wire --wait into the submitting
command. The generator emits a new jobs parent command (list/get/prune)
backed by a JSONL ledger at ~/.<cli>-pp-cli/jobs.jsonl.

Every --wait invocation polls the detected status endpoint with
exponential-backoff + jitter (2s initial, 30s cap, 10m default timeout)
and records state transitions to the ledger. The agent gets one-shot
"submit and block to final artifact" semantics instead of having to
write its own polling loop.

Mapped from the HeyGen CLI post (2026-04-13): "one command, submit
through delivery."

Files touched

Diff

commit 1c172850d5882ea89cf00d3e74090c47cdcab6de
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Fri Apr 17 22:58:58 2026 -0400

    feat(cli): async-job detection, --wait flag, jobs command
    
    Detect endpoints that submit long-running work (job-id-shaped response
    field + sibling status endpoint) and wire --wait into the submitting
    command. The generator emits a new jobs parent command (list/get/prune)
    backed by a JSONL ledger at ~/.<cli>-pp-cli/jobs.jsonl.
    
    Every --wait invocation polls the detected status endpoint with
    exponential-backoff + jitter (2s initial, 30s cap, 10m default timeout)
    and records state transitions to the ledger. The agent gets one-shot
    "submit and block to final artifact" semantics instead of having to
    write its own polling loop.
    
    Mapped from the HeyGen CLI post (2026-04-13): "one command, submit
    through delivery."
---
 internal/generator/async_detect.go                 | 179 +++++++++++
 internal/generator/async_detect_test.go            | 193 ++++++++++++
 internal/generator/generator.go                    |  43 ++-
 .../generator/templates/command_endpoint.go.tmpl   |  60 ++++
 internal/generator/templates/jobs.go.tmpl          | 350 +++++++++++++++++++++
 internal/generator/templates/root.go.tmpl          |   3 +
 internal/generator/templates/skill.md.tmpl         |  20 ++
 7 files changed, 844 insertions(+), 4 deletions(-)

diff --git a/internal/generator/async_detect.go b/internal/generator/async_detect.go
new file mode 100644
index 00000000..566ed0ed
--- /dev/null
+++ b/internal/generator/async_detect.go
@@ -0,0 +1,179 @@
+package generator
+
+import (
+	"regexp"
+	"strings"
+
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+)
+
+// AsyncJobInfo describes one async-job endpoint detected from a spec.
+// Populated at generation time and carried into template data so generated
+// commands can expose --wait and a shared jobs store.
+type AsyncJobInfo struct {
+	// ResourceName and EndpointName identify the submitting endpoint
+	// (the one that returns a job ID).
+	ResourceName string
+	EndpointName string
+
+	// JobIDField is the response field name that carries the job identifier
+	// (e.g. "job_id", "task_id"). Empty when detection fell back to
+	// path-sibling inference.
+	JobIDField string
+
+	// StatusResource and StatusEndpoint point at the sibling status
+	// endpoint used by the --wait polling loop. Empty only if no sibling
+	// was found (in which case the endpoint is not marked async).
+	StatusResource string
+	StatusEndpoint string
+
+	// StatusPath is the URL path template for the polling endpoint,
+	// copied from the sibling endpoint's Path. The runtime substitutes
+	// {id} or {job_id} with the actual job ID.
+	StatusPath string
+
+	// TerminalField and TerminalValues describe how the polling loop
+	// decides the job is done. Defaults to "status" with a common
+	// done/complete/completed/failed/errored set when the spec does not
+	// declare one explicitly.
+	TerminalField  string
+	TerminalValues []string
+}
+
+// jobIDFieldPattern matches field names that typically carry a job or
+// long-running-operation identifier in API responses.
+var jobIDFieldPattern = regexp.MustCompile(`(?i)^(job|task|operation|request|async|run|batch)_?id$`)
+
+// terminalValueDefaults is the set of status values treated as terminal
+// when the spec does not declare a status enum on the sibling endpoint.
+var terminalValueDefaults = []string{"done", "complete", "completed", "success", "succeeded", "finished", "failed", "errored", "cancelled", "canceled"}
+
+// DetectAsyncJobs walks an APISpec and returns one AsyncJobInfo per detected
+// async-job endpoint, keyed by "<resourceName>/<endpointName>".
+//
+// Detection requires both strong signals:
+//
+//  1. The endpoint's response payload contains a field whose name matches
+//     jobIDFieldPattern (job_id, task_id, operation_id, ...).
+//  2. A sibling endpoint exists that looks like a status probe for the same
+//     resource - either a GET on the same resource (common get/status/show),
+//     a same-resource sibling whose name contains "status" or "poll", or an
+//     endpoint in a separate jobs/operations/status-flavored resource.
+//
+// Both signals are load-bearing: the job-id field names what to track and the
+// sibling names where to poll. A plain CRUD POST that happens to have a sibling
+// GET is not marked async - without a job-id-shaped response field, we do not
+// assume the endpoint is long-running.
+//
+// HTTP status code 202 would be a third signal; the internal spec model does
+// not track per-status-code responses, so we omit it and rely on the two
+// remaining signals.
+//
+// The function is pure - it does not mutate the spec. The caller decides
+// how to use the detection map (template data, dogfood checks, scorecard).
+func DetectAsyncJobs(s *spec.APISpec) map[string]AsyncJobInfo {
+	out := map[string]AsyncJobInfo{}
+	if s == nil {
+		return out
+	}
+
+	for rName, r := range s.Resources {
+		for eName, ep := range r.Endpoints {
+			info, ok := detectOne(s, rName, eName, ep)
+			if !ok {
+				continue
+			}
+			out[rName+"/"+eName] = info
+		}
+	}
+	return out
+}
+
+func detectOne(s *spec.APISpec, rName, eName string, ep spec.Endpoint) (AsyncJobInfo, bool) {
+	jobField := responseJobIDField(s, ep)
+	if jobField == "" {
+		return AsyncJobInfo{}, false
+	}
+	statusRes, statusEP := findStatusSibling(s, rName, eName)
+	if statusEP == "" {
+		return AsyncJobInfo{}, false
+	}
+
+	statusPath := ""
+	if r, ok := s.Resources[statusRes]; ok {
+		if sep, ok := r.Endpoints[statusEP]; ok {
+			statusPath = sep.Path
+		}
+	}
+
+	return AsyncJobInfo{
+		ResourceName:   rName,
+		EndpointName:   eName,
+		JobIDField:     jobField,
+		StatusResource: statusRes,
+		StatusEndpoint: statusEP,
+		StatusPath:     statusPath,
+		TerminalField:  "status",
+		TerminalValues: terminalValueDefaults,
+	}, true
+}
+
+// responseJobIDField returns the matching job-id-shaped field name in the
+// endpoint's response type, or "" if none.
+func responseJobIDField(s *spec.APISpec, ep spec.Endpoint) string {
+	if ep.Response.Item == "" {
+		return ""
+	}
+	td, ok := s.Types[ep.Response.Item]
+	if !ok {
+		return ""
+	}
+	for _, f := range td.Fields {
+		if jobIDFieldPattern.MatchString(f.Name) {
+			return f.Name
+		}
+	}
+	return ""
+}
+
+// findStatusSibling looks for a sibling endpoint that functions as the
+// polling target for an async job submitted by (rName, eName). Returns the
+// resource/endpoint of the sibling, or ("","") if none is found.
+func findStatusSibling(s *spec.APISpec, rName, eName string) (string, string) {
+	// Same-resource sibling by common name
+	r, ok := s.Resources[rName]
+	if ok {
+		for _, candidate := range []string{"get", "status", "show", "retrieve"} {
+			if _, exists := r.Endpoints[candidate]; exists && candidate != eName {
+				return rName, candidate
+			}
+		}
+		// Name-based sibling: <eName>_status or status_<eName>
+		for cName := range r.Endpoints {
+			low := strings.ToLower(cName)
+			if cName == eName {
+				continue
+			}
+			if strings.Contains(low, "status") || strings.Contains(low, "poll") {
+				return rName, cName
+			}
+		}
+	}
+	// Separate resource named <rName>_status / <rName>_jobs
+	for otherName, other := range s.Resources {
+		if otherName == rName {
+			continue
+		}
+		low := strings.ToLower(otherName)
+		if !(strings.Contains(low, "status") || strings.Contains(low, "job") || strings.Contains(low, "task") || strings.Contains(low, "operation")) {
+			continue
+		}
+		for candName := range other.Endpoints {
+			cl := strings.ToLower(candName)
+			if strings.Contains(cl, "get") || strings.Contains(cl, "status") || strings.Contains(cl, "show") || strings.Contains(cl, "retrieve") {
+				return otherName, candName
+			}
+		}
+	}
+	return "", ""
+}
diff --git a/internal/generator/async_detect_test.go b/internal/generator/async_detect_test.go
new file mode 100644
index 00000000..0aa8015b
--- /dev/null
+++ b/internal/generator/async_detect_test.go
@@ -0,0 +1,193 @@
+package generator
+
+import (
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestDetectAsyncJobs(t *testing.T) {
+	tests := []struct {
+		name       string
+		spec       *spec.APISpec
+		wantKey    string
+		wantStatus string
+		wantJobID  string
+		wantCount  int
+	}{
+		{
+			name: "POST + job_id response + sibling get: detected",
+			spec: &spec.APISpec{
+				Types: map[string]spec.TypeDef{
+					"VideoJob": {Fields: []spec.TypeField{
+						{Name: "job_id", Type: "string"},
+						{Name: "status", Type: "string"},
+					}},
+				},
+				Resources: map[string]spec.Resource{
+					"videos": {
+						Endpoints: map[string]spec.Endpoint{
+							"create": {Method: "POST", Path: "/videos", Response: spec.ResponseDef{Type: "object", Item: "VideoJob"}},
+							"get":    {Method: "GET", Path: "/videos/{id}", Response: spec.ResponseDef{Type: "object", Item: "VideoJob"}},
+						},
+					},
+				},
+			},
+			wantKey:    "videos/create",
+			wantStatus: "get",
+			wantJobID:  "job_id",
+			wantCount:  1,
+		},
+		{
+			name: "POST + task_id response + _status sibling: detected",
+			spec: &spec.APISpec{
+				Types: map[string]spec.TypeDef{
+					"TaskResp": {Fields: []spec.TypeField{{Name: "task_id", Type: "string"}}},
+				},
+				Resources: map[string]spec.Resource{
+					"renders": {
+						Endpoints: map[string]spec.Endpoint{
+							"submit":        {Method: "POST", Path: "/renders", Response: spec.ResponseDef{Type: "object", Item: "TaskResp"}},
+							"render_status": {Method: "GET", Path: "/renders/{id}/status"},
+						},
+					},
+				},
+			},
+			wantKey:    "renders/submit",
+			wantStatus: "render_status",
+			wantJobID:  "task_id",
+			wantCount:  1,
+		},
+		{
+			name: "cross-resource: jobs resource holds status endpoint",
+			spec: &spec.APISpec{
+				Types: map[string]spec.TypeDef{
+					"OpResp": {Fields: []spec.TypeField{{Name: "operation_id", Type: "string"}}},
+				},
+				Resources: map[string]spec.Resource{
+					"transfers": {
+						Endpoints: map[string]spec.Endpoint{
+							"create": {Method: "POST", Path: "/transfers", Response: spec.ResponseDef{Type: "object", Item: "OpResp"}},
+						},
+					},
+					"operations": {
+						Endpoints: map[string]spec.Endpoint{
+							"get": {Method: "GET", Path: "/operations/{id}"},
+						},
+					},
+				},
+			},
+			wantKey:    "transfers/create",
+			wantStatus: "get",
+			wantJobID:  "operation_id",
+			wantCount:  1,
+		},
+		{
+			name: "POST with id-field but no sibling: NOT detected (status is load-bearing)",
+			spec: &spec.APISpec{
+				Types: map[string]spec.TypeDef{
+					"R": {Fields: []spec.TypeField{{Name: "job_id", Type: "string"}}},
+				},
+				Resources: map[string]spec.Resource{
+					"things": {
+						Endpoints: map[string]spec.Endpoint{
+							"create": {Method: "POST", Path: "/things", Response: spec.ResponseDef{Type: "object", Item: "R"}},
+						},
+					},
+				},
+			},
+			wantCount: 0,
+		},
+		{
+			name: "GET with job_id-field and sibling: detected (field+sibling both fire)",
+			spec: &spec.APISpec{
+				Types: map[string]spec.TypeDef{
+					"R": {Fields: []spec.TypeField{{Name: "job_id", Type: "string"}}},
+				},
+				Resources: map[string]spec.Resource{
+					"things": {
+						Endpoints: map[string]spec.Endpoint{
+							"list": {Method: "GET", Path: "/things", Response: spec.ResponseDef{Type: "array", Item: "R"}},
+							"get":  {Method: "GET", Path: "/things/{id}"},
+						},
+					},
+				},
+			},
+			wantKey:    "things/list",
+			wantStatus: "get",
+			wantJobID:  "job_id",
+			wantCount:  1,
+		},
+		{
+			name: "plain CRUD POST without id-shaped field: NOT detected",
+			spec: &spec.APISpec{
+				Types: map[string]spec.TypeDef{
+					"User": {Fields: []spec.TypeField{
+						{Name: "id", Type: "string"},
+						{Name: "name", Type: "string"},
+					}},
+				},
+				Resources: map[string]spec.Resource{
+					"users": {
+						Endpoints: map[string]spec.Endpoint{
+							"create": {Method: "POST", Path: "/users", Response: spec.ResponseDef{Type: "object", Item: "User"}},
+							"get":    {Method: "GET", Path: "/users/{id}"},
+						},
+					},
+				},
+			},
+			wantCount: 0,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			got := DetectAsyncJobs(tc.spec)
+			assert.Len(t, got, tc.wantCount, "detection count")
+			if tc.wantKey != "" {
+				info, ok := got[tc.wantKey]
+				assert.True(t, ok, "expected detection at %q", tc.wantKey)
+				assert.Equal(t, tc.wantStatus, info.StatusEndpoint, "status endpoint")
+				assert.Equal(t, tc.wantJobID, info.JobIDField, "job id field")
+			}
+		})
+	}
+}
+
+func TestDetectAsyncJobs_NilSpec(t *testing.T) {
+	assert.Empty(t, DetectAsyncJobs(nil))
+}
+
+func TestResponseJobIDField_VariousNames(t *testing.T) {
+	cases := []struct {
+		field  string
+		expect bool
+	}{
+		{"job_id", true},
+		{"jobId", true},
+		{"task_id", true},
+		{"operation_id", true},
+		{"request_id", true},
+		{"async_id", true},
+		{"run_id", true},
+		{"batch_id", true},
+		{"id", false},
+		{"user_id", false},
+		{"jobs", false},
+	}
+	for _, c := range cases {
+		s := &spec.APISpec{
+			Types: map[string]spec.TypeDef{
+				"T": {Fields: []spec.TypeField{{Name: c.field, Type: "string"}}},
+			},
+		}
+		ep := spec.Endpoint{Response: spec.ResponseDef{Item: "T"}}
+		got := responseJobIDField(s, ep)
+		if c.expect {
+			assert.Equal(t, c.field, got, "should match: %s", c.field)
+		} else {
+			assert.Empty(t, got, "should not match: %s", c.field)
+		}
+	}
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 47a6c7a3..37d2b3ec 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -116,10 +116,11 @@ type Generator struct {
 	OutputDir      string
 	VisionSet      VisionTemplateSet
 	FixtureSet     *websniff.FixtureSet
-	Sources        []ReadmeSource   // Ecosystem tools to credit in README
-	DiscoveryPages []string         // Pages visited during sniff discovery
-	NovelFeatures  []NovelFeature   // Transcendence features for README/SKILL
-	Narrative      *ReadmeNarrative // LLM-authored prose for README/SKILL; optional
+	Sources        []ReadmeSource          // Ecosystem tools to credit in README
+	DiscoveryPages []string                // Pages visited during sniff discovery
+	NovelFeatures  []NovelFeature          // Transcendence features for README/SKILL
+	Narrative      *ReadmeNarrative        // LLM-authored prose for README/SKILL; optional
+	AsyncJobs      map[string]AsyncJobInfo // Detected async-job endpoints, keyed by "<resource>/<endpoint>"
 	profile        *profiler.APIProfile
 	funcs          template.FuncMap
 	templates      map[string]*template.Template
@@ -645,6 +646,13 @@ func (g *Generator) Generate() error {
 		g.profile = profiler.Profile(g.Spec)
 	}
 
+	// Detect async-job endpoints once per generation. Results flow into
+	// per-endpoint template data (for conditional --wait emission) and into
+	// the root template (for the jobs command registration).
+	if g.AsyncJobs == nil {
+		g.AsyncJobs = DetectAsyncJobs(g.Spec)
+	}
+
 	// Generate single files
 	singleFiles := map[string]string{
 		"main.go.tmpl":           filepath.Join("cmd", naming.CLI(g.Spec.Name), "main.go"),
@@ -753,6 +761,7 @@ func (g *Generator) Generate() error {
 			if promotedEndpointNames[name] == eName {
 				continue
 			}
+			asyncInfo, isAsync := g.AsyncJobs[name+"/"+eName]
 			epData := struct {
 				ResourceName string
 				FuncPrefix   string
@@ -760,6 +769,8 @@ func (g *Generator) Generate() error {
 				EndpointName string
 				Endpoint     spec.Endpoint
 				HasStore     bool
+				IsAsync      bool
+				Async        AsyncJobInfo
 				*spec.APISpec
 			}{
 				ResourceName: name,
@@ -768,6 +779,8 @@ func (g *Generator) Generate() error {
 				EndpointName: eName,
 				Endpoint:     endpoint,
 				HasStore:     g.VisionSet.Store,
+				IsAsync:      isAsync,
+				Async:        asyncInfo,
 				APISpec:      g.Spec,
 			}
 			epPath := filepath.Join("internal", "cli", name+"_"+eName+".go")
@@ -805,6 +818,8 @@ func (g *Generator) Generate() error {
 			}
 
 			for eName, endpoint := range subResource.Endpoints {
+				subKey := subName + "/" + eName
+				asyncInfo, isAsync := g.AsyncJobs[subKey]
 				epData := struct {
 					ResourceName string
 					FuncPrefix   string
@@ -812,6 +827,8 @@ func (g *Generator) Generate() error {
 					EndpointName string
 					Endpoint     spec.Endpoint
 					HasStore     bool
+					IsAsync      bool
+					Async        AsyncJobInfo
 					*spec.APISpec
 				}{
 					ResourceName: subName,
@@ -820,6 +837,8 @@ func (g *Generator) Generate() error {
 					EndpointName: eName,
 					Endpoint:     endpoint,
 					HasStore:     g.VisionSet.Store,
+					IsAsync:      isAsync,
+					Async:        asyncInfo,
 					APISpec:      g.Spec,
 				}
 				epPath := filepath.Join("internal", "cli", name+"_"+subName+"_"+eName+".go")
@@ -1138,6 +1157,8 @@ func (g *Generator) Generate() error {
 		Narrative             *ReadmeNarrative
 		TopNovelFeatures      []NovelFeature
 		NovelOverflowCount    int
+		HasAsyncJobs          bool
+		AsyncJobCount         int
 	}{
 		APISpec:               g.Spec,
 		VisionSet:             g.VisionSet,
@@ -1149,10 +1170,24 @@ func (g *Generator) Generate() error {
 		Narrative:             g.Narrative,
 		TopNovelFeatures:      shownNovel,
 		NovelOverflowCount:    overflow,
+		HasAsyncJobs:          len(g.AsyncJobs) > 0,
+		AsyncJobCount:         len(g.AsyncJobs),
 	}
 	if err := g.renderTemplate("root.go.tmpl", filepath.Join("internal", "cli", "root.go"), rootData); err != nil {
 		return fmt.Errorf("rendering root: %w", err)
 	}
+	if len(g.AsyncJobs) > 0 {
+		jobsData := struct {
+			*spec.APISpec
+			AsyncJobs map[string]AsyncJobInfo
+		}{
+			APISpec:   g.Spec,
+			AsyncJobs: g.AsyncJobs,
+		}
+		if err := g.renderTemplate("jobs.go.tmpl", filepath.Join("internal", "cli", "jobs.go"), jobsData); err != nil {
+			return fmt.Errorf("rendering jobs: %w", err)
+		}
+	}
 	if err := g.renderTemplate("go.mod.tmpl", "go.mod", rootData); err != nil {
 		return fmt.Errorf("rendering go.mod: %w", err)
 	}
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 582706e9..fa968dfd 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -4,12 +4,18 @@
 package cli
 
 import (
+{{- if .IsAsync}}
+	"context"
+{{- end}}
 	"encoding/json"
 	"fmt"
 {{- if or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")}}
 	"io"
 {{- end}}
 	"os"
+{{- if .IsAsync}}
+	"time"
+{{- end}}
 
 	"github.com/spf13/cobra"
 )
@@ -29,6 +35,11 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- if or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")}}
 	var stdinBody bool
 {{- end}}
+{{- if .IsAsync}}
+	var flagWait bool
+	var flagWaitTimeout time.Duration
+	var flagWaitInterval time.Duration
+{{- end}}
 
 	cmd := &cobra.Command{
 		Use:   "{{.EndpointName}}{{positionalArgs .Endpoint}}",
@@ -249,6 +260,50 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 			}
 {{- end}}
 
+{{- if .IsAsync}}
+			if asyncJobID := ExtractJobID(data, "{{.Async.JobIDField}}"); asyncJobID != "" {
+				_ = RecordJob(JobRow{
+					JobID:          asyncJobID,
+					Resource:       "{{.ResourceName}}",
+					Endpoint:       "{{.EndpointName}}",
+					Status:         "submitted",
+					StatusResource: "{{.Async.StatusResource}}",
+					StatusEndpoint: "{{.Async.StatusEndpoint}}",
+				})
+				if flagWait {
+					ctx := cmd.Context()
+					if ctx == nil {
+						ctx = context.Background()
+					}
+					final, werr := WaitForJob(ctx, c, "{{.Async.StatusPath}}", asyncJobID, WaitOptions{
+						Interval: flagWaitInterval,
+						Timeout:  flagWaitTimeout,
+					})
+					if werr != nil {
+						_ = RecordJob(JobRow{
+							JobID:    asyncJobID,
+							Resource: "{{.ResourceName}}",
+							Endpoint: "{{.EndpointName}}",
+							Status:   "errored",
+							Error:    werr.Error(),
+						})
+						return werr
+					}
+					if b, merr := json.Marshal(final); merr == nil {
+						data = b
+					}
+					if st, _ := final["status"].(string); st != "" {
+						_ = RecordJob(JobRow{
+							JobID:    asyncJobID,
+							Resource: "{{.ResourceName}}",
+							Endpoint: "{{.EndpointName}}",
+							Status:   st,
+						})
+					}
+				}
+			}
+{{- end}}
+
 {{- if not (or (eq .Endpoint.Method "GET") (eq .Endpoint.Method "HEAD"))}}
 			if wantsHumanTable(cmd.OutOrStdout(), flags) {
 				// Check if response contains an array (directly or wrapped in "data")
@@ -363,6 +418,11 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- if or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")}}
 	cmd.Flags().BoolVar(&stdinBody, "stdin", false, "Read request body as JSON from stdin")
 {{- end}}
+{{- if .IsAsync}}
+	cmd.Flags().BoolVar(&flagWait, "wait", false, "Block until the submitted job reaches a terminal status")
+	cmd.Flags().DurationVar(&flagWaitTimeout, "wait-timeout", 10*time.Minute, "Maximum duration to wait when --wait is set (0 = no timeout)")
+	cmd.Flags().DurationVar(&flagWaitInterval, "wait-interval", 2*time.Second, "Initial poll interval when --wait is set")
+{{- end}}
 
 	return cmd
 }
diff --git a/internal/generator/templates/jobs.go.tmpl b/internal/generator/templates/jobs.go.tmpl
new file mode 100644
index 00000000..61eecdcc
--- /dev/null
+++ b/internal/generator/templates/jobs.go.tmpl
@@ -0,0 +1,350 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"math/rand"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/spf13/cobra"
+
+	"{{modulePath}}/internal/client"
+)
+
+// JobRow is one entry in the local jobs ledger. Rows are appended as NDJSON
+// to ~/.{{.Name}}-pp-cli/jobs.jsonl; the latest row for a given JobID wins
+// when listing. Pruning rewrites the file without old entries.
+type JobRow struct {
+	JobID          string    `json:"job_id"`
+	Resource       string    `json:"resource"`
+	Endpoint       string    `json:"endpoint"`
+	Status         string    `json:"status"`
+	SubmittedAt    time.Time `json:"submitted_at"`
+	UpdatedAt      time.Time `json:"updated_at"`
+	StatusResource string    `json:"status_resource,omitempty"`
+	StatusEndpoint string    `json:"status_endpoint,omitempty"`
+	Error          string    `json:"error,omitempty"`
+}
+
+func jobsFilePath() (string, error) {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return "", fmt.Errorf("resolving home dir: %w", err)
+	}
+	dir := filepath.Join(home, ".{{.Name}}-pp-cli")
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return "", fmt.Errorf("creating state dir: %w", err)
+	}
+	return filepath.Join(dir, "jobs.jsonl"), nil
+}
+
+// RecordJob appends a JobRow to the local ledger.
+func RecordJob(row JobRow) error {
+	p, err := jobsFilePath()
+	if err != nil {
+		return err
+	}
+	if row.SubmittedAt.IsZero() {
+		row.SubmittedAt = time.Now().UTC()
+	}
+	row.UpdatedAt = time.Now().UTC()
+	f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+	if err != nil {
+		return fmt.Errorf("opening jobs ledger: %w", err)
+	}
+	defer f.Close()
+	return json.NewEncoder(f).Encode(row)
+}
+
+func readJobRows() ([]JobRow, error) {
+	p, err := jobsFilePath()
+	if err != nil {
+		return nil, err
+	}
+	data, err := os.ReadFile(p)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, nil
+		}
+		return nil, fmt.Errorf("reading jobs ledger: %w", err)
+	}
+	var rows []JobRow
+	for _, line := range strings.Split(string(data), "\n") {
+		line = strings.TrimSpace(line)
+		if line == "" {
+			continue
+		}
+		var r JobRow
+		if err := json.Unmarshal([]byte(line), &r); err != nil {
+			continue // skip malformed rows rather than fail the whole list
+		}
+		rows = append(rows, r)
+	}
+	return rows, nil
+}
+
+// latestByJobID returns one row per JobID (the most recent by UpdatedAt).
+func latestByJobID(rows []JobRow) []JobRow {
+	byID := map[string]JobRow{}
+	for _, r := range rows {
+		existing, ok := byID[r.JobID]
+		if !ok || r.UpdatedAt.After(existing.UpdatedAt) {
+			byID[r.JobID] = r
+		}
+	}
+	out := make([]JobRow, 0, len(byID))
+	for _, r := range byID {
+		out = append(out, r)
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].SubmittedAt.After(out[j].SubmittedAt) })
+	return out
+}
+
+// WaitOptions configures WaitForJob polling.
+type WaitOptions struct {
+	Interval time.Duration // initial poll interval; grows with backoff up to 30s
+	Timeout  time.Duration // overall wait budget; 0 means no timeout
+}
+
+// WaitForJob polls statusPath with exponential backoff + jitter until the
+// response reports a terminal status or the timeout elapses. Returns the
+// final response body as a parsed map, or an error.
+//
+// Terminal statuses recognised:
+//   done, complete, completed, success, succeeded, finished,
+//   failed, errored, cancelled, canceled
+func WaitForJob(ctx context.Context, c *client.Client, statusPath string, jobID string, opts WaitOptions) (map[string]any, error) {
+	interval := opts.Interval
+	if interval <= 0 {
+		interval = 2 * time.Second
+	}
+	deadline := time.Time{}
+	if opts.Timeout > 0 {
+		deadline = time.Now().Add(opts.Timeout)
+	}
+	maxInterval := 30 * time.Second
+
+	path := strings.ReplaceAll(statusPath, "{id}", jobID)
+	path = strings.ReplaceAll(path, "{job_id}", jobID)
+
+	for {
+		if !deadline.IsZero() && time.Now().After(deadline) {
+			return nil, fmt.Errorf("wait timed out after %s (job %s)", opts.Timeout, jobID)
+		}
+		if err := ctx.Err(); err != nil {
+			return nil, err
+		}
+
+		resp, err := c.Get(path, nil)
+		if err == nil {
+			var body map[string]any
+			if uerr := json.Unmarshal(resp, &body); uerr == nil {
+				if isJobTerminal(body) {
+					return body, nil
+				}
+			}
+		}
+
+		// Jittered sleep: interval + random up to 25%.
+		jitter := time.Duration(rand.Int63n(int64(interval) / 4 + 1))
+		select {
+		case <-time.After(interval + jitter):
+		case <-ctx.Done():
+			return nil, ctx.Err()
+		}
+
+		// Exponential-ish growth with cap.
+		interval = interval * 3 / 2
+		if interval > maxInterval {
+			interval = maxInterval
+		}
+	}
+}
+
+// ExtractJobID parses response bytes and returns the string value of the
+// named field (e.g. "job_id"). Returns "" if the response is not JSON or the
+// field is missing / not a string.
+func ExtractJobID(data []byte, field string) string {
+	if len(data) == 0 || field == "" {
+		return ""
+	}
+	var body map[string]any
+	if err := json.Unmarshal(data, &body); err != nil {
+		return ""
+	}
+	if v, ok := body[field].(string); ok {
+		return v
+	}
+	// Some APIs nest the job under "data" or "job".
+	for _, wrapper := range []string{"data", "job", "result"} {
+		if inner, ok := body[wrapper].(map[string]any); ok {
+			if v, ok := inner[field].(string); ok {
+				return v
+			}
+		}
+	}
+	return ""
+}
+
+func isJobTerminal(body map[string]any) bool {
+	status, _ := body["status"].(string)
+	if status == "" {
+		return false
+	}
+	switch strings.ToLower(status) {
+	case "done", "complete", "completed", "success", "succeeded", "finished",
+		"failed", "errored", "cancelled", "canceled":
+		return true
+	}
+	return false
+}
+
+// newJobsCmd registers the `jobs` parent command and its subcommands.
+func newJobsCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "jobs",
+		Short: "List and inspect async jobs tracked by this CLI",
+		Long: `Jobs tracked when you submit an async-capable endpoint land in
+~/.{{.Name}}-pp-cli/jobs.jsonl. This command lists, inspects, and prunes them.
+
+Submit an async endpoint with --wait to block until completion; submit
+without --wait to get the job ID back immediately and track it later.`,
+	}
+	cmd.AddCommand(newJobsListCmd(flags))
+	cmd.AddCommand(newJobsGetCmd(flags))
+	cmd.AddCommand(newJobsPruneCmd(flags))
+	return cmd
+}
+
+func newJobsListCmd(flags *rootFlags) *cobra.Command {
+	var limit int
+	var all bool
+	cmd := &cobra.Command{
+		Use:   "list",
+		Short: "List recent async jobs",
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			rows, err := readJobRows()
+			if err != nil {
+				return err
+			}
+			if !all {
+				rows = latestByJobID(rows)
+			}
+			if limit > 0 && limit < len(rows) {
+				rows = rows[:limit]
+			}
+			if flags.asJSON {
+				return flags.printJSON(cmd, rows)
+			}
+			headers := []string{"JOB_ID", "RESOURCE", "STATUS", "SUBMITTED", "UPDATED"}
+			tbl := make([][]string, 0, len(rows))
+			for _, r := range rows {
+				tbl = append(tbl, []string{
+					r.JobID,
+					r.Resource + "/" + r.Endpoint,
+					r.Status,
+					r.SubmittedAt.Format(time.RFC3339),
+					r.UpdatedAt.Format(time.RFC3339),
+				})
+			}
+			return flags.printTable(cmd, headers, tbl)
+		},
+	}
+	cmd.Flags().IntVar(&limit, "limit", 50, "Maximum rows to list (0 for unlimited)")
+	cmd.Flags().BoolVar(&all, "all", false, "Show every state transition rather than the latest row per job")
+	return cmd
+}
+
+func newJobsGetCmd(flags *rootFlags) *cobra.Command {
+	return &cobra.Command{
+		Use:   "get <job-id>",
+		Short: "Show the latest state row for a job",
+		Args:  cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			rows, err := readJobRows()
+			if err != nil {
+				return err
+			}
+			rows = latestByJobID(rows)
+			for _, r := range rows {
+				if r.JobID == args[0] {
+					if flags.asJSON {
+						return flags.printJSON(cmd, r)
+					}
+					fmt.Fprintf(cmd.OutOrStdout(), "job_id:    %s\nresource:  %s/%s\nstatus:    %s\nsubmitted: %s\nupdated:   %s\n",
+						r.JobID, r.Resource, r.Endpoint, r.Status, r.SubmittedAt.Format(time.RFC3339), r.UpdatedAt.Format(time.RFC3339))
+					if r.Error != "" {
+						fmt.Fprintf(cmd.OutOrStdout(), "error:     %s\n", r.Error)
+					}
+					return nil
+				}
+			}
+			return fmt.Errorf("job %q not found in local ledger", args[0])
+		},
+	}
+}
+
+func newJobsPruneCmd(flags *rootFlags) *cobra.Command {
+	var olderThan time.Duration
+	cmd := &cobra.Command{
+		Use:   "prune",
+		Short: "Remove job rows older than --older-than",
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			rows, err := readJobRows()
+			if err != nil {
+				return err
+			}
+			cutoff := time.Now().Add(-olderThan)
+			var kept []JobRow
+			pruned := 0
+			for _, r := range rows {
+				if r.UpdatedAt.Before(cutoff) {
+					pruned++
+					continue
+				}
+				kept = append(kept, r)
+			}
+
+			p, err := jobsFilePath()
+			if err != nil {
+				return err
+			}
+			tmp := p + ".tmp"
+			f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
+			if err != nil {
+				return fmt.Errorf("opening tmp ledger: %w", err)
+			}
+			enc := json.NewEncoder(f)
+			for _, r := range kept {
+				if err := enc.Encode(r); err != nil {
+					_ = f.Close()
+					_ = os.Remove(tmp)
+					return fmt.Errorf("writing tmp ledger: %w", err)
+				}
+			}
+			if err := f.Close(); err != nil {
+				_ = os.Remove(tmp)
+				return err
+			}
+			if err := os.Rename(tmp, p); err != nil {
+				return fmt.Errorf("replacing ledger: %w", err)
+			}
+
+			if flags.asJSON {
+				return flags.printJSON(cmd, map[string]any{"pruned": pruned, "kept": len(kept)})
+			}
+			fmt.Fprintf(cmd.OutOrStdout(), "pruned %d, kept %d\n", pruned, len(kept))
+			return nil
+		},
+	}
+	cmd.Flags().DurationVar(&olderThan, "older-than", 7*24*time.Hour, "Prune rows whose latest update is older than this duration")
+	return cmd
+}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index ad5f87e9..2353582c 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -141,6 +141,9 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
 	rootCmd.AddCommand(newDoctorCmd(&flags))
 	rootCmd.AddCommand(newAuthCmd(&flags))
 	rootCmd.AddCommand(newAgentContextCmd(rootCmd))
+{{- if .HasAsyncJobs}}
+	rootCmd.AddCommand(newJobsCmd(&flags))
+{{- end}}
 {{- if .VisionSet.Export}}
 	rootCmd.AddCommand(newExportCmd(&flags))
 {{- end}}
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index fefef615..0969497c 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -146,6 +146,26 @@ Add `--agent` to any command. Expands to: `--json --compact --no-input --no-colo
 - **Cacheable** — GET responses cached for 5 minutes, bypass with `--no-cache`
 - **Non-interactive** — never prompts, every input is a flag
 
+## Async Jobs
+
+For endpoints that submit long-running work, the generator detects the submit-then-poll pattern (a `job_id`/`task_id`/`operation_id` field in the response plus a sibling status endpoint) and wires up three extra flags on the submitting command:
+
+| Flag | Purpose |
+|------|---------|
+| `--wait` | Block until the job reaches a terminal status instead of returning the job ID immediately |
+| `--wait-timeout` | Maximum wait duration (default 10m, 0 means no timeout) |
+| `--wait-interval` | Initial poll interval (default 2s; grows with exponential backoff up to 30s) |
+
+Every `--wait` invocation records the job to `~/.<cli>-pp-cli/jobs.jsonl`. Inspect the ledger with:
+
+```
+<cli>-pp-cli jobs list [--json] [--limit N]
+<cli>-pp-cli jobs get <job-id>
+<cli>-pp-cli jobs prune --older-than 7d
+```
+
+Use async submission without `--wait` when you want to fire-and-forget; use `--wait` when you want one command to return the finished artifact.
+
 ## Exit Codes
 
 | Code | Meaning |

← d4297f63 docs(cli): plan HeyGen CLI learnings application  ·  back to Cli Printing Press  ·  feat(cli): named-profile system for repeatable agent context 7140c3e7 →