diff --git a/internal/publish/submission.go b/internal/publish/submission.go index 4df1707..de937ac 100644 --- a/internal/publish/submission.go +++ b/internal/publish/submission.go @@ -180,7 +180,7 @@ type SubMethod struct { // $APP/secrets.json under SecretKey, from which the byo ${TOKEN} headers resolve // it). One SubSignup describes one leg, selected by Step. type SubSignup struct { - Step string `json:"step"` // "create" | "register" | "verify" | "broker" | "account" + Step string `json:"step"` // "create" | "register" | "verify" | "broker" | "account" | "set_key" URL string `json:"url"` // create/register/verify: the provider endpoint POSTed BrokerURL string `json:"broker_url"` // broker: the Pilot broker /signup endpoint (signed) KeyPath string `json:"key_path"` // create/verify: dotted path to the key (create defaults to data.api_key) @@ -220,6 +220,20 @@ type SubRoute struct { // metadata file so a SubLocal method can recall it (e.g. buy_number → // ~/.pilot/.agentphone). Best-effort; never fails the call. CaptureTo string `json:"capture_to"` + // Public marks an endpoint the provider serves without credentials; it is + // exempt from the adapter's no-key-yet gate (see scaffold.HTTPRoute.Public). + Public bool `json:"public"` + // SaveKey makes this route the one that issues the byo API key: the string + // at Path in its JSON answer is cached under SecretKey and redacted from the + // reply (see scaffold.HTTPRoute.SaveKey). Start names the signup's first method. + SaveKey *SubSaveKey `json:"save_key,omitempty"` +} + +// SubSaveKey is scaffold.SaveKeyRoute in submission form. +type SubSaveKey struct { + Path string `json:"path"` + SecretKey string `json:"secret_key"` + Start string `json:"start,omitempty"` } // SubCLIRoute is the backend CLI mapping for a method. Enumerated methods bake @@ -468,12 +482,19 @@ func validateSubSignupMethod(n string, m SubMethod) []string { if strings.TrimSpace(m.Signup.SecretKey) == "" { e = append(e, fmt.Sprintf("Method %q: a broker signup step needs signup.secret_key", n)) } + case "set_key": + if strings.TrimSpace(m.Signup.SecretKey) == "" { + e = append(e, fmt.Sprintf("Method %q: a set_key step needs signup.secret_key", n)) + } + if strings.TrimSpace(m.Signup.URL) != "" { + https("url", m.Signup.URL) + } case "account": if strings.TrimSpace(m.Signup.SecretKey) == "" { e = append(e, fmt.Sprintf("Method %q: an account step needs signup.secret_key", n)) } default: - e = append(e, fmt.Sprintf("Method %q: signup.step must be create|register|verify|broker|account", n)) + e = append(e, fmt.Sprintf("Method %q: signup.step must be create|register|verify|broker|account|set_key", n)) } if m.HasHTTP() || m.HasCLI() || m.HasLocal() { e = append(e, fmt.Sprintf("Method %q: a signup method must not also declare an http/cli/local route", n)) @@ -717,7 +738,10 @@ func (s Submission) ToConfig() *scaffold.Config { Passthrough: m.CLI.Passthrough, } default: - route := &scaffold.HTTPRoute{Verb: orDefault(m.HTTP.Verb, "GET"), Path: m.HTTP.Path, CaptureTo: m.HTTP.CaptureTo} + route := &scaffold.HTTPRoute{Verb: orDefault(m.HTTP.Verb, "GET"), Path: m.HTTP.Path, CaptureTo: m.HTTP.CaptureTo, Public: m.HTTP.Public} + if sk := m.HTTP.SaveKey; sk != nil { + route.SaveKey = &scaffold.SaveKeyRoute{Path: sk.Path, SecretKey: sk.SecretKey, Start: sk.Start} + } // Carry each param's explicit request location so the generator can // resolve query/path/path_raw/body/header placement. Omitted `in` // keeps the verb/path default (back-compat). diff --git a/internal/scaffold/config.go b/internal/scaffold/config.go index bf15347..afd4fd7 100644 --- a/internal/scaffold/config.go +++ b/internal/scaffold/config.go @@ -312,19 +312,27 @@ func (c *Config) Provisioned() bool { return c.Backend.Auth == "provisioned" } // locally, so — like a provisioned app — it needs fs.read+fs.write on that file. func (c *Config) HasSignup() bool { for _, m := range c.Methods { - if m.Signup != nil { + if m.Signup != nil || m.saveKey() != nil { return true } } return false } +// saveKey returns the method's key-issuing http route config, or nil. +func (m Method) saveKey() *SaveKeyRoute { + if m.HTTP != nil { + return m.HTTP.SaveKey + } + return nil +} + // HasKeyMintSignup reports whether any method mints the byo auth key itself (a // create/verify/broker signup route). When true, the generated adapter soft-fails // unauthenticated calls with activation instructions instead of a raw 401. func (c *Config) HasKeyMintSignup() bool { for _, m := range c.Methods { - if m.Signup != nil && (m.Signup.IsCreate() || m.Signup.IsVerify() || m.Signup.IsBroker()) { + if (m.Signup != nil && m.Signup.mintsKey()) || m.saveKey() != nil { return true } } @@ -335,9 +343,12 @@ func (c *Config) HasKeyMintSignup() bool { // (the first key-minting signup route's SecretKey), or "". func (c *Config) AuthSecretKey() string { for _, m := range c.Methods { - if m.Signup != nil && (m.Signup.IsCreate() || m.Signup.IsVerify() || m.Signup.IsBroker()) { + if m.Signup != nil && m.Signup.mintsKey() { return m.Signup.SecretKey } + if sk := m.saveKey(); sk != nil { + return sk.SecretKey + } } return "" } @@ -355,9 +366,71 @@ func (c *Config) SignupMethodName() string { return m.Name } } + for _, m := range c.Methods { + if sk := m.saveKey(); sk != nil { + if sk.Start != "" { + return sk.Start + } + return m.Name + } + } + for _, m := range c.Methods { + if m.Signup != nil && m.Signup.IsSetKey() { + return m.Name + } + } return "" } +// SignupHint is the one-line activation instruction the adapter returns, in +// place of a doomed 401, when an authenticated call arrives before any key is +// on the host. It names the method SignupMethodName picks and says what to pass. +func (c *Config) SignupHint() string { + name := c.SignupMethodName() + for _, m := range c.Methods { + if sk := m.saveKey(); sk != nil && (name == sk.Start || name == m.Name) { + issuer := m.Name + n := 0 + for _, o := range c.Methods { + if o.saveKey() != nil { + n++ + } + } + if n > 1 { + issuer = "the provider" // several steps can issue it (e.g. sign-in vs new account) + } + hint := "No API key on this host yet. Call " + name + " to start signup; the key is stored on this host as soon as " + + issuer + " issues it, and injected on every call automatically." + for _, o := range c.Methods { + if o.Signup != nil && o.Signup.IsSetKey() { + hint += " Already have a key? Save it with " + o.Name + "." + } + } + return hint + } + } + for _, m := range c.Methods { + if m.Name != name || m.Signup == nil { + continue + } + switch { + case m.Signup.IsSetKey(): + where := "from your account with the provider" + if m.Signup.URL != "" { + where = "at " + m.Signup.URL + } + return "No API key on this host yet. Create one " + where + ", then call " + name + + ` once with {"api_key":"..."} — it is stored locally under ~/.pilot and injected on every call automatically.` + case strings.EqualFold(m.Signup.Step, "register"): + return "No API key on this host yet. Call " + name + + " to start signup, then finish with the verify method — the key is then stored locally under ~/.pilot and injected on every call automatically." + } + } + return "No API key on this host yet. Call " + name + + " once (no arguments required) to provision a free account and managed inbox — " + + "the key is then stored locally under ~/.pilot and injected on every call automatically; you never pass it." +} + // HasBrokerSignup reports whether any signup route is the broker step — the // adapter then needs an ed25519 signer + identity (to sign the keyless broker // call) and key.sign + net.dial-broker grants, even though its ops stay byo. @@ -701,7 +774,7 @@ type Method struct { // {email, api_key}. The adapter caches BOTH to secrets.json; ops stay byo. // No user email, no code, one call. type SignupRoute struct { - Step string `yaml:"step"` // "create" | "register" | "verify" | "broker" | "account" + Step string `yaml:"step"` // "create" | "register" | "verify" | "broker" | "account" | "set_key" URL string `yaml:"url"` // create/register/verify: the provider endpoint POSTed BrokerURL string `yaml:"broker_url"` // broker: the Pilot broker /signup endpoint (signed) KeyPath string `yaml:"key_path"` // create/verify: dotted path to the key in the response (default application.api_key; create defaults to data.api_key) @@ -722,6 +795,16 @@ func (s *SignupRoute) IsVerify() bool { return strings.EqualFold(s.Step, "verify // IsBroker is the fully-autonomous leg that signs a call to the Pilot broker. func (s *SignupRoute) IsBroker() bool { return strings.EqualFold(s.Step, "broker") } +// IsSetKey is the plain byo leg: the user obtains a key from the provider +// themselves (URL, when set, says where) and hands it to this local method, +// which caches it under SecretKey. No backend call is made. +func (s *SignupRoute) IsSetKey() bool { return strings.EqualFold(s.Step, "set_key") } + +// mintsKey reports whether this route is what puts the byo auth key on the host. +func (s *SignupRoute) mintsKey() bool { + return s.IsCreate() || s.IsVerify() || s.IsBroker() || s.IsSetKey() +} + // BodyJSON renders the static create body as a compact JSON object literal (or // "{}"), baked into the generated adapter and re-parsed at runtime. func (s *SignupRoute) BodyJSON() string { @@ -735,6 +818,15 @@ func (s *SignupRoute) BodyJSON() string { return string(b) } +// SaveKeyRoute says where a route's response carries the API key it issues. +type SaveKeyRoute struct { + Path string `yaml:"path"` // dotted path to the key in the JSON response, e.g. apiKey or data.api_key + SecretKey string `yaml:"secret_key"` // secrets.json key it is cached under, e.g. DIAL_API_KEY + // Start names the method an agent calls to begin the signup that ends in + // this route (e.g. dial.signup); it is what the no-key-yet hint points to. + Start string `yaml:"start"` +} + // LocalRoute makes a method run entirely on the host with NO backend call: it // reads a local JSON metadata file (see HTTPRoute.CaptureTo, which writes it) and // returns its contents. Used for host-local state an agent should recall without @@ -835,6 +927,19 @@ type HTTPRoute struct { // the provisioned number to ~/.pilot/.agentphone. CaptureTo string `yaml:"capture_to"` + // Public marks an endpoint the provider serves without credentials (a + // catalogue, a recommendation, a signup handshake). An app whose key comes + // from a signup route soft-fails authenticated calls until a key exists; + // a public route is exempt, so it keeps working before signup. + Public bool `yaml:"public"` + + // SaveKey, when set, makes this route the one that issues the byo API key + // (a provider whose signup returns the key from an ordinary endpoint, e.g. + // Dial's /auth/verify-number). On a 2xx JSON answer the string at Path is + // cached under SecretKey in $APP/secrets.json and replaced in the reply, so + // the key is never handed to the agent and every later call carries it. + SaveKey *SaveKeyRoute `yaml:"save_key"` + // Multipart, when set, sends this method as multipart/form-data built from a // staged blob rather than as a JSON body. See MultipartRoute. Multipart *MultipartRoute `yaml:"multipart"` @@ -1442,13 +1547,21 @@ func (c *Config) validateSignupMethod(i int, m Method) []error { if strings.TrimSpace(m.Signup.SecretKey) == "" { errs = append(errs, fmt.Errorf("methods[%d] (%s): a broker signup step needs signup.secret_key (the key the minted secret is cached under)", i, m.Name)) } + case "set_key": + // A local writer of a user-supplied key — no backend call to validate. + if strings.TrimSpace(m.Signup.SecretKey) == "" { + errs = append(errs, fmt.Errorf("methods[%d] (%s): a set_key step needs signup.secret_key (the key the supplied secret is cached under)", i, m.Name)) + } + if strings.TrimSpace(m.Signup.URL) != "" { + httpsURL("url", m.Signup.URL) + } case "account": // A local reader of the cached account — needs only the secrets keys. if strings.TrimSpace(m.Signup.SecretKey) == "" { errs = append(errs, fmt.Errorf("methods[%d] (%s): an account step needs signup.secret_key", i, m.Name)) } default: - errs = append(errs, fmt.Errorf("methods[%d] (%s): signup.step %q must be create|register|verify|broker|account", i, m.Name, m.Signup.Step)) + errs = append(errs, fmt.Errorf("methods[%d] (%s): signup.step %q must be create|register|verify|broker|account|set_key", i, m.Name, m.Signup.Step)) } if c.Managed() { errs = append(errs, fmt.Errorf("methods[%d] (%s): a signup (no-broker) route cannot be combined with managed/provisioned auth", i, m.Name)) @@ -1469,6 +1582,23 @@ func (c *Config) validateHTTPMethod(i int, m Method) []error { if m.HTTP.Path == "" || !strings.HasPrefix(m.HTTP.Path, "/") { errs = append(errs, fmt.Errorf("methods[%d].http.path must start with /", i)) } + if sk := m.HTTP.SaveKey; sk != nil { + if strings.TrimSpace(sk.Path) == "" || strings.TrimSpace(sk.SecretKey) == "" { + errs = append(errs, fmt.Errorf("methods[%d] (%s): http.save_key needs both path and secret_key", i, m.Name)) + } + if c.Managed() { + errs = append(errs, fmt.Errorf("methods[%d] (%s): http.save_key cannot be combined with managed/provisioned auth", i, m.Name)) + } + if sk.Start != "" { + found := false + for _, o := range c.Methods { + found = found || o.Name == sk.Start + } + if !found { + errs = append(errs, fmt.Errorf("methods[%d] (%s): http.save_key.start %q is not a method of this app", i, m.Name, sk.Start)) + } + } + } switch m.HTTP.Verb { case "GET", "POST", "PATCH", "PUT", "DELETE": default: diff --git a/internal/scaffold/templates/main.go.tmpl b/internal/scaffold/templates/main.go.tmpl index b0d53c9..7fbf31f 100644 --- a/internal/scaffold/templates/main.go.tmpl +++ b/internal/scaffold/templates/main.go.tmpl @@ -201,7 +201,12 @@ func registerHandlers(d *ipc.Dispatcher, c *backend.Client, version, backendURL rawPathParams: []string{ {{- range $p := .HTTP.RawPathParams}}{{printf "%q" $p}}, {{end -}} }, }, manifestPath, dur("{{.TimeoutFor}}"))) // {{.Duration}} (multipart upload) {{- else if .Signup}} -{{- if eq .Signup.Step "account"}} +{{- if .Signup.IsSetKey}} + d.Register("{{.Name}}", setKeyHandler(setKeyConfig{ + secretKey: {{printf "%q" .Signup.SecretKey}}, + keyURL: {{printf "%q" .Signup.URL}}, + }, manifestPath)) // {{.Duration}} (local: cache a key you obtained from the provider) +{{- else if eq .Signup.Step "account"}} d.Register("{{.Name}}", accountHandler(accountConfig{ secretKey: {{printf "%q" .Signup.SecretKey}}, emailKey: {{printf "%q" .Signup.EmailKey}}, @@ -238,7 +243,7 @@ func registerHandlers(d *ipc.Dispatcher, c *backend.Client, version, backendURL }, manifestPath, dur("{{.TimeoutFor}}"))) // {{.Duration}} (no-broker self-signup: register) {{- end}} {{- else}} - d.Register("{{.Name}}", {{if $.HasKeyMintSignup}}requireKey(manifestPath, {{printf "%q" $.AuthSecretKey}}, {{printf "%q" $.SignupMethodName}}, {{end}}{{if .HTTP.CaptureTo}}captureWrap(expandHome("{{.HTTP.CaptureTo}}"), {{end}}forward(c, route{ + d.Register("{{.Name}}", {{if and $.HasKeyMintSignup (not .HTTP.Public)}}requireKey(manifestPath, {{printf "%q" $.AuthSecretKey}}, {{printf "%q" $.SignupMethodName}}, {{printf "%q" $.SignupHint}}, {{end}}{{if .HTTP.SaveKey}}saveKeyWrap(manifestPath, {{printf "%q" .HTTP.SaveKey.Path}}, {{printf "%q" .HTTP.SaveKey.SecretKey}}, {{end}}{{if .HTTP.CaptureTo}}captureWrap(expandHome("{{.HTTP.CaptureTo}}"), {{end}}forward(c, route{ method: "{{.HTTP.Verb}}", pathTmpl: "{{.HTTP.Path}}", bodyVerb: {{.HTTP.BodyVerb}}, pathParams: []string{ {{- range $p := .HTTP.PathParams}}{{printf "%q" $p}}, {{end -}} }, rawPathParams: []string{ {{- range $p := .HTTP.RawPathParams}}{{printf "%q" $p}}, {{end -}} }, @@ -246,7 +251,7 @@ func registerHandlers(d *ipc.Dispatcher, c *backend.Client, version, backendURL bodyParams: []string{ {{- range $p := .HTTP.BodyParams}}{{printf "%q" $p}}, {{end -}} }, headerParams: []string{ {{- range $p := .HTTP.HeaderParams}}{{printf "%q" $p}}, {{end -}} }, bodyRawParam: "{{.HTTP.BodyRawParam}}", - }, dur("{{.TimeoutFor}}")){{if .HTTP.CaptureTo}}){{end}}{{if $.HasKeyMintSignup}}){{end}}) // {{.Duration}} + }, dur("{{.TimeoutFor}}")){{if .HTTP.CaptureTo}}){{end}}{{if .HTTP.SaveKey}}){{end}}{{if and $.HasKeyMintSignup (not .HTTP.Public)}}){{end}}) // {{.Duration}} {{- end}} {{- end}} d.Register("{{.Namespace}}.help", helpHandler(version, backendURL)) @@ -288,16 +293,14 @@ func localRead(store string) ipc.Handler { // method mints the key, it is stored locally under $APP/secrets.json (in ~/.pilot) // and the adapter injects it into the Authorization header on every subsequent // call automatically — the agent never sees or passes the key. -func requireKey(manifestPath, secretKey, signupMethod string, h ipc.Handler) ipc.Handler { +func requireKey(manifestPath, secretKey, signupMethod, hint string, h ipc.Handler) ipc.Handler { return func(ctx context.Context, req *ipc.Envelope) (json.RawMessage, error) { if os.Getenv(secretKey) == "" && loadSecrets(manifestPath)[secretKey] == "" { msg := map[string]any{ "ok": false, "needs_signup": true, "activate": signupMethod, - "message": "No API key on this host yet. Call " + signupMethod + - " once (no arguments required) to provision a free account and managed inbox — " + - "the key is then stored locally under ~/.pilot and injected on every call automatically; you never pass it.", + "message": hint, } b, _ := json.Marshal(msg) return json.RawMessage(b), nil diff --git a/internal/scaffold/templates/signup.go.tmpl b/internal/scaffold/templates/signup.go.tmpl index c9e4a78..eec2a5b 100644 --- a/internal/scaffold/templates/signup.go.tmpl +++ b/internal/scaffold/templates/signup.go.tmpl @@ -285,6 +285,104 @@ func accountHandler(ac accountConfig, manifestPath string) ipc.Handler { } } +// setKeyConfig / setKeyHandler are the plain byo leg: the user creates a key at +// the provider (keyURL says where) and hands it over once. It is cached under +// secretKey in $APP/secrets.json and injected on every later call. Unlike the +// minting steps this OVERWRITES a cached key, so a revoked or mistyped key can be +// replaced without touching files by hand. Local, no backend call. +type setKeyConfig struct { + secretKey string + keyURL string +} + +func setKeyHandler(sc setKeyConfig, manifestPath string) ipc.Handler { + return func(ctx context.Context, req *ipc.Envelope) (json.RawMessage, error) { + var in struct { + APIKey string `json:"api_key"` + } + if len(req.Payload) > 0 { + _ = json.Unmarshal(req.Payload, &in) + } + in.APIKey = strings.TrimSpace(in.APIKey) + if in.APIKey == "" { + where := "" + if sc.keyURL != "" { + where = " (create one at " + sc.keyURL + ")" + } + return nil, errors.New("set_key: api_key is required — pass {\"api_key\":\"...\"}" + where) + } + dir := signupAppDir(manifestPath) + if dir == "" { + return nil, errors.New("set_key: cannot locate the app directory to cache the key") + } + path := filepath.Join(dir, "secrets.json") + m := signupReadSecrets(path) + replaced := m[sc.secretKey] != "" + m[sc.secretKey] = in.APIKey + if err := signupWriteSecrets(path, m); err != nil { + return nil, fmt.Errorf("set_key: cache key: %w", err) + } + return signupResult(map[string]any{ + "ok": true, "key_field": sc.secretKey, "api_key": "saved", "replaced": replaced, + "message": "key cached to secrets.json and used on every subsequent call", + }) + } +} + +// saveKeyWrap wraps a route whose response issues the API key (http.save_key). +// On a successful JSON object answer carrying a non-empty string at keyPath, +// the key is cached under secretKey in $APP/secrets.json — overwriting, since +// the provider has just issued a new one — and the reply is rewritten so the +// agent sees that it was saved rather than the key itself. Any other answer +// passes through untouched. +func saveKeyWrap(manifestPath, keyPath, secretKey string, h ipc.Handler) ipc.Handler { + return func(ctx context.Context, req *ipc.Envelope) (json.RawMessage, error) { + out, err := h(ctx, req) + if err != nil { + return out, err + } + key := signupDigString(out, keyPath) + if key == "" { + return out, nil + } + dir := signupAppDir(manifestPath) + if dir == "" { + return nil, errors.New("save_key: cannot locate the app directory to cache the key — the key was not stored; sign in again") + } + path := filepath.Join(dir, "secrets.json") + m := signupReadSecrets(path) + m[secretKey] = key + if err := signupWriteSecrets(path, m); err != nil { + return nil, fmt.Errorf("save_key: cache key: %w", err) + } + var obj map[string]any + if json.Unmarshal(out, &obj) != nil { + return signupResult(map[string]any{"ok": true, "key_field": secretKey, "api_key": "saved"}) + } + redactPath(obj, keyPath, "saved on this host as "+secretKey+"; sent on every call") + return signupResult(obj) + } +} + +// redactPath replaces the value at a dotted path in decoded JSON. +func redactPath(obj map[string]any, dotted, with string) { + parts := strings.Split(dotted, ".") + cur := obj + for i, p := range parts { + if i == len(parts)-1 { + if _, ok := cur[p]; ok { + cur[p] = with + } + return + } + next, ok := cur[p].(map[string]any) + if !ok { + return + } + cur = next + } +} + // ── shared helpers ────────────────────────────────────────────────────────── func signupResult(m map[string]any) (json.RawMessage, error) { @@ -326,6 +424,11 @@ func signupMergeSecrets(path string, add map[string]string) error { m[k] = v } } + return signupWriteSecrets(path, m) +} + +// signupWriteSecrets atomically replaces path (0600) with m. +func signupWriteSecrets(path string, m map[string]string) error { out, err := json.MarshalIndent(m, "", " ") if err != nil { return err diff --git a/internal/scaffold/zz_save_key_e2e_test.go b/internal/scaffold/zz_save_key_e2e_test.go new file mode 100644 index 0000000..6604c08 --- /dev/null +++ b/internal/scaffold/zz_save_key_e2e_test.go @@ -0,0 +1,167 @@ +//go:build !windows + +package scaffold + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/pilot-protocol/app-store/pkg/ipc" +) + +// saveKeySpec mirrors Dial: signup is ordinary endpoints, and the key arrives +// once in the body of a verify call rather than from a signup route. +const saveKeySpec = ` +id: io.pilot.savekeyx +app_version: 0.1.0 +description: "App whose verify endpoint issues the API key." +namespace: savekeyx +backend: + base_url: https://placeholder.invalid + auth: byo + headers: + Authorization: "Bearer ${SAVEKEYX_API_KEY}" +methods: + - name: savekeyx.signup + summary: "Start signup." + http: { verb: POST, path: "/auth/signup", public: true } + params: { email: the email } + - name: savekeyx.verify + summary: "Finish signup; returns the key once." + http: { verb: POST, path: "/auth/verify", public: true, save_key: { path: account.apiKey, secret_key: SAVEKEYX_API_KEY, start: savekeyx.signup } } + params: { code: the code } + - name: savekeyx.me + summary: "Authenticated read." + http: { verb: GET, path: "/me" } +` + +func TestSaveKeyStoresRedactsAndSends(t *testing.T) { + cfg := parseSpec(t, saveKeySpec) + if errs := cfg.Validate(); len(errs) != 0 { + t.Fatalf("spec invalid: %v", errs) + } + if !cfg.HasSignup() || !cfg.HasKeyMintSignup() || cfg.AuthSecretKey() != "SAVEKEYX_API_KEY" { + t.Fatalf("save_key route should count as the key-minting route") + } + if got := cfg.SignupMethodName(); got != "savekeyx.signup" { + t.Errorf("SignupMethodName()=%q, want the save_key start method", got) + } + if testing.Short() { + t.Skip("builds and runs a real adapter binary; skipped under -short") + } + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go toolchain not available") + } + + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/auth/verify": + _, _ = w.Write([]byte(`{"account":{"id":"acc_1","apiKey":"sk_live_secret"},"created":true}`)) + case "/me": + gotAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"id":"acc_1"}`)) + default: + _, _ = w.Write([]byte(`{"verificationId":"v_1"}`)) + } + })) + defer srv.Close() + + root := t.TempDir() + proj := filepath.Join(root, "proj") + if _, err := Generate(cfg, proj); err != nil { + t.Fatalf("generate: %v", err) + } + if sum, err := os.ReadFile(filepath.Join("..", "..", "go.sum")); err == nil { + _ = os.WriteFile(filepath.Join(proj, "go.sum"), sum, 0o644) + } + bin := filepath.Join(root, "adapter") + build := exec.Command("go", "build", "-o", bin, "./cmd/"+cfg.BinaryName) + build.Dir = proj + build.Env = append(os.Environ(), "GOFLAGS=-mod=mod") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build adapter: %v\n%s", err, out) + } + sockDir, err := os.MkdirTemp("", "skx") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(sockDir) + sock := filepath.Join(sockDir, "a.sock") + adapter := exec.Command(bin, "--socket", sock, "--manifest", filepath.Join(proj, "manifest.json")) + adapter.Stderr = os.Stderr + adapter.Env = append(os.Environ(), "SAVEKEYX_BACKEND_URL="+srv.URL) + if err := adapter.Start(); err != nil { + t.Fatalf("start adapter: %v", err) + } + defer func() { _ = adapter.Process.Kill(); _, _ = adapter.Process.Wait() }() + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) { + if _, err := os.Stat(sock); err == nil { + break + } + } + call := func(method, args string) string { + t.Helper() + conn, err := net.DialTimeout("unix", sock, 3*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + var out json.RawMessage + if err := ipc.Call(conn, method, json.RawMessage(args), &out); err != nil { + t.Fatalf("call %s: %v", method, err) + } + return string(out) + } + + // Before any key: the authenticated method points at the signup start. + if out := call("savekeyx.me", `{}`); !strings.Contains(out, `"activate":"savekeyx.signup"`) { + t.Errorf("no-key call should point at savekeyx.signup: %s", out) + } + // Public signup works keyless. + if out := call("savekeyx.signup", `{"email":"a@b.c"}`); !strings.Contains(out, "v_1") { + t.Errorf("signup: %s", out) + } + // verify issues the key: it is stored, and the reply no longer carries it. + out := call("savekeyx.verify", `{"code":"123456"}`) + if strings.Contains(out, "sk_live_secret") { + t.Fatalf("key leaked to the agent: %s", out) + } + if !strings.Contains(out, "saved on this host") || !strings.Contains(out, "acc_1") { + t.Errorf("verify reply should keep the rest and mark the key saved: %s", out) + } + sec, _ := os.ReadFile(filepath.Join(proj, "secrets.json")) + if !strings.Contains(string(sec), `"SAVEKEYX_API_KEY": "sk_live_secret"`) { + t.Errorf("key not cached: %s", sec) + } + // Later calls carry it. + call("savekeyx.me", `{}`) + if gotAuth != "Bearer sk_live_secret" { + t.Errorf("Authorization = %q, want the saved key", gotAuth) + } +} + +func TestSaveKeyValidation(t *testing.T) { + for name, spec := range map[string]string{ + "missing secret_key": strings.Replace(saveKeySpec, ", secret_key: SAVEKEYX_API_KEY", "", 1), + "unknown start": strings.Replace(saveKeySpec, "start: savekeyx.signup", "start: savekeyx.nope", 1), + } { + cfg, err := Parse([]byte(spec)) + if err != nil { + t.Fatalf("%s: parse: %v", name, err) + } + cfg.Resolve() + if errs := cfg.Validate(); len(errs) == 0 { + t.Errorf("%s: expected a validation error", name) + } + } +} diff --git a/internal/scaffold/zz_set_key_test.go b/internal/scaffold/zz_set_key_test.go new file mode 100644 index 0000000..78f2dde --- /dev/null +++ b/internal/scaffold/zz_set_key_test.go @@ -0,0 +1,124 @@ +package scaffold + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// setKeySpec is a plain byo app: the user creates a key at the provider and +// hands it to .set_key; every other method resolves it from secrets.json. +const setKeySpec = ` +id: io.pilot.rentahuman +app_version: 0.2.1 +description: "Hire humans for real-world tasks with your own RentAHuman API key." +backend: + type: http + base_url: https://rentahuman.ai + auth: byo + headers: + X-API-Key: "${RENTAHUMAN_API_KEY}" +methods: + - name: rentahuman.set_key + summary: "Save the RentAHuman API key this host uses." + duration: fast + signup: + step: set_key + url: https://rentahuman.ai/account/api-keys + secret_key: RENTAHUMAN_API_KEY + - name: rentahuman.list_bounties + summary: "List open bounties." + duration: fast + http: {verb: GET, path: /api/partner/v1/bounties} + - name: rentahuman.list_humans + summary: "Browse humans (public)." + duration: fast + http: {verb: GET, path: /api/humans, public: true} +` + +func TestSetKeyStepGeneratesAndCompiles(t *testing.T) { + cfg := parseSpec(t, setKeySpec) + if !cfg.HasSignup() || !cfg.HasKeyMintSignup() { + t.Fatal("HasSignup() and HasKeyMintSignup() should be true for a set_key step") + } + if got := cfg.AuthSecretKey(); got != "RENTAHUMAN_API_KEY" { + t.Errorf("AuthSecretKey()=%q, want RENTAHUMAN_API_KEY", got) + } + if got := cfg.SignupMethodName(); got != "rentahuman.set_key" { + t.Errorf("SignupMethodName()=%q, want rentahuman.set_key", got) + } + hint := cfg.SignupHint() + for _, want := range []string{"rentahuman.set_key", "https://rentahuman.ai/account/api-keys", `"api_key"`} { + if !strings.Contains(hint, want) { + t.Errorf("SignupHint() %q missing %q", hint, want) + } + } + if strings.Contains(hint, "no arguments required") { + t.Errorf("set_key hint must not claim no arguments are needed: %q", hint) + } + + dir := t.TempDir() + if _, err := Generate(cfg, dir); err != nil { + t.Fatalf("generate: %v", err) + } + main, _ := os.ReadFile(filepath.Join(dir, "cmd", cfg.BinaryName, "main.go")) + for _, want := range []string{"setKeyHandler(setKeyConfig{", "requireKey(", "rentahuman.ai/account/api-keys", "HeaderFunc:"} { + if !strings.Contains(string(main), want) { + t.Errorf("generated main.go missing %q", want) + } + } + // The gated route is wrapped; the public one is not. + if !strings.Contains(string(main), `requireKey(manifestPath, "RENTAHUMAN_API_KEY", "rentahuman.set_key", `) { + t.Error("list_bounties should be wrapped in requireKey") + } + for _, line := range strings.Split(string(main), "\n") { + if strings.Contains(line, `d.Register("rentahuman.list_humans"`) && strings.Contains(line, "requireKey(") { + t.Errorf("public route must not be gated on a key: %s", line) + } + } + mf, _ := os.ReadFile(filepath.Join(dir, "manifest.json")) + if !strings.Contains(string(mf), `"cap": "fs.write", "target": "$APP/secrets.json"`) { + t.Errorf("manifest must grant fs.write on secrets.json for set_key") + } + if testing.Short() { + return + } + compileGenerated(t, dir) +} + +func TestSetKeyStepValidation(t *testing.T) { + validate := func(spec string) []error { + t.Helper() + cfg, err := Parse([]byte(spec)) + if err != nil { + t.Fatalf("parse: %v", err) + } + cfg.Resolve() + return cfg.Validate() + } + if errs := validate(setKeySpec); len(errs) != 0 { + t.Fatalf("valid set_key spec rejected: %v", errs) + } + bad := strings.Replace(setKeySpec, " secret_key: RENTAHUMAN_API_KEY\n", "", 1) + errs := validate(bad) + found := false + for _, e := range errs { + found = found || strings.Contains(e.Error(), "set_key step needs signup.secret_key") + } + if !found { + t.Errorf("missing secret_key: errs=%v, want the set_key secret_key error", errs) + } + insecure := strings.Replace(setKeySpec, "https://rentahuman.ai/account/api-keys", "http://rentahuman.ai/account/api-keys", 1) + if errs := validate(insecure); len(errs) == 0 { + t.Error("an http:// set_key url should be rejected") + } +} + +// The register flow's hint must not tell agents to call it with no arguments. +func TestSignupHintRegisterStep(t *testing.T) { + cfg := parseSpec(t, signupSpec) + if h := cfg.SignupHint(); strings.Contains(h, "no arguments required") || !strings.Contains(h, "didit.signup") { + t.Errorf("register hint = %q", h) + } +} diff --git a/submissions/io.pilot.aegis/submission.json b/submissions/io.pilot.aegis/submission.json index f7af49d..7548d07 100644 --- a/submissions/io.pilot.aegis/submission.json +++ b/submissions/io.pilot.aegis/submission.json @@ -1,6 +1,6 @@ { "id": "io.pilot.aegis", - "version": "0.1.3", + "version": "0.1.4", "description": "AEGIS is a runtime firewall for AI agents: scan files, commands, and tool results for prompt injection, jailbreaks, homoglyph/leetspeak obfuscation, and impersonation before your agent acts on them. An 880 KB, fully-offline Rust binary — L1 Aho-Corasick pattern matching (microseconds) plus an optional local judge model. 90% recall / 95% precision on a held-out set.", "email": "apps@pilotprotocol.network", "backend": { @@ -133,41 +133,52 @@ "url": "https://aegis.pilotprotocol.network", "contact": "apps@pilotprotocol.network", "agent_usage": "Scan untrusted content before acting on it: aegis.scan for files/dirs; aegis.exec {\"args\":[\"scan-cmd\"],\"stdin\":...} as a PreToolUse gate (exit 0 allow / 2 block) and {\"args\":[\"scan-result\"],\"stdin\":...} as a PostToolUse check. aegis.status tails the audit log; aegis.targets/aegis.config introspect. Fully offline.", - "capabilities": "Delivers the AEGIS 0.1.3 runtime firewall as a sha-pinned, relocatable per-OS/arch bundle. L1 Aho-Corasick patterns (pure Rust, microseconds) plus an optional local Qwen3-1.7B judge (llama.cpp, offline). 90% recall / 95% precision on a held-out set. HMAC-chained audit log. MIT." + "capabilities": "Delivers the AEGIS 0.1.4 runtime firewall as a sha-pinned, relocatable per-OS/arch bundle. L1 Aho-Corasick patterns (pure Rust, microseconds) plus an optional local Qwen3-1.7B judge (llama.cpp, offline). 90% recall / 95% precision on a held-out set. HMAC-chained audit log. MIT." }, "artifacts": [ { "role": "binary", "os": "darwin", "arch": "arm64", - "url": "https://pub-2328865fa11041b8a5efba00b940ec14.r2.dev/io.pilot.aegis/0.1.3/darwin-arm64/aegis-0.1.3-darwin-arm64.tar.gz", - "sha256": "dc20fc8c70aa0516d5fd503709a5c3c1e7f91964591bfa378c8bfe946db85b01", - "size": 448175, + "url": "https://pub-2328865fa11041b8a5efba00b940ec14.r2.dev/io.pilot.aegis/0.1.4/darwin-arm64/aegis-0.1.4-darwin-arm64.tar.gz", + "sha256": "5fc1037ab60e20d26640809d39d012d9d41087ab5faab4e2cc85ede974847bdf", + "size": 468831, "unpack": "tar.gz", - "exec_path": "aegis-0.1.3-darwin-arm64/bin/aegis", + "exec_path": "aegis-0.1.4-darwin-arm64/bin/aegis", + "order": 1 + }, + { + "role": "binary", + "os": "darwin", + "arch": "amd64", + "url": "https://pub-2328865fa11041b8a5efba00b940ec14.r2.dev/io.pilot.aegis/0.1.4/darwin-amd64/aegis-0.1.4-darwin-amd64.tar.gz", + "sha256": "4556f59f2fe6f01ec370a3e26eff6b799c1f364b442db15102e5960483710e3b", + "size": 527693, + "unpack": "tar.gz", + "exec_path": "aegis-0.1.4-darwin-amd64/bin/aegis", "order": 1 }, { "role": "binary", "os": "linux", "arch": "amd64", - "url": "https://pub-2328865fa11041b8a5efba00b940ec14.r2.dev/io.pilot.aegis/0.1.3/linux-amd64/aegis-0.1.3-linux-amd64.tar.gz", - "sha256": "5cb700460a4072894429fa0f90038d9d597fac0b9e160d7c1090a04ad3ea3745", - "size": 571791, + "url": "https://pub-2328865fa11041b8a5efba00b940ec14.r2.dev/io.pilot.aegis/0.1.4/linux-amd64/aegis-0.1.4-linux-amd64.tar.gz", + "sha256": "47642a275ffe35eec5e5150274937c3c5bbb95263d8a61a65a6e9773fd24e47f", + "size": 608346, "unpack": "tar.gz", - "exec_path": "aegis-0.1.3-linux-amd64/bin/aegis", - "order": 2 + "exec_path": "aegis-0.1.4-linux-amd64/bin/aegis", + "order": 1 }, { "role": "binary", "os": "linux", "arch": "arm64", - "url": "https://pub-2328865fa11041b8a5efba00b940ec14.r2.dev/io.pilot.aegis/0.1.3/linux-arm64/aegis-0.1.3-linux-arm64.tar.gz", - "sha256": "1fd9156ef23bb755965d9d6511f6ba2345bc409233dd010acb20be0ea956b857", - "size": 505315, + "url": "https://pub-2328865fa11041b8a5efba00b940ec14.r2.dev/io.pilot.aegis/0.1.4/linux-arm64/aegis-0.1.4-linux-arm64.tar.gz", + "sha256": "b3564ada80f6c3a0118c402eabcf1c08e0e9430a4a3ffde7d8464858c0f154b9", + "size": 538648, "unpack": "tar.gz", - "exec_path": "aegis-0.1.3-linux-arm64/bin/aegis", - "order": 3 + "exec_path": "aegis-0.1.4-linux-arm64/bin/aegis", + "order": 1 } ], "product_demo": { diff --git a/submissions/io.pilot.deadsimple/submission.json b/submissions/io.pilot.deadsimple/submission.json new file mode 100644 index 0000000..28ff9f5 --- /dev/null +++ b/submissions/io.pilot.deadsimple/submission.json @@ -0,0 +1,796 @@ +{ + "product_demo": { + "skill": "io.pilot.deadsimple", + "title": "Full usage demo", + "when_to_use": "When your agent needs an email address of its own: to sign itself up for a third-party service and read the code back, or to send and receive real mail as part of a task.", + "metered": false, + "quickstart": { + "goal": "Get an account, an API key and a live inbox in one call", + "command": "pilotctl appstore call io.pilot.deadsimple deadsimple.signup '{\"label\":\"my-agent\"}'", + "expect": "{\"data\":{\"account_id\":\"...\",\"api_key\":\"dse_...\",\"inbox\":{\"inbox_id\":\"...\",\"email\":\"...@box1.deadsimple.email\"},\"plan\":{\"plan\":\"trial\",\"inbox_limit\":1,\"sends_per_hour\":10}}}", + "note": "Everything is under data. Save data.api_key as DEADSIMPLE_API_KEY \u2014 it is shown once. Already have a key? Skip this and call create_inbox." + }, + "examples": [ + { + "title": "Sign up for a third-party service and read the code back", + "goal": "The whole point: clear an email verification without a human", + "command": "pilotctl appstore call io.pilot.deadsimple deadsimple.get_verification_code '{\"inbox_id\":\"\",\"since\":\"2026-08-06T12:00:00Z\",\"from_contains\":\"stripe.com\"}'", + "expect": "{\"data\":{\"found\":true,\"code\":\"481920\",\"magic_link\":null,\"message_id\":\"...\"}}", + "note": "Note the time BEFORE submitting your address, pass it as `since`, then poll every 2-3s. found=false just means it has not landed yet. Never parse the raw email yourself." + }, + { + "title": "Send a real email", + "command": "pilotctl appstore call io.pilot.deadsimple deadsimple.send_email '{\"inbox_id\":\"\",\"to\":\"someone@example.com\",\"subject\":\"Report ready\",\"text_body\":\"The run finished.\"}'", + "expect": "{\"data\":{\"message_id\":\"...\",\"status\":\"queued\"}}", + "note": "DKIM-signed egress, not a sandbox. Trial caps this at 10/hour, 25/day." + }, + { + "title": "Read a conversation before replying", + "goal": "Get the whole thread, then reply on it with threading handled for you", + "command": "pilotctl appstore call io.pilot.deadsimple deadsimple.get_thread '{\"inbox_id\":\"\",\"thread_id\":\"\"}'", + "expect": "{\"data\":{\"thread_id\":\"...\",\"messages\":[{\"message_id\":\"...\",\"from\":\"...\",\"subject\":\"...\"}]}}", + "note": "Then reply with the message_id; threading headers are set for you." + } + ], + "gotchas": [ + "Every response is wrapped: the payload is under data, with meta.request_id beside it.", + "Call signup only when you have no key. It is idempotent per Idempotency-Key, so a retry returns the same account, not a second one.", + "Trial is 1 inbox, 10 sends/hour, 25/day. A 429 trial_send_limit_exceeded is a QUOTA signal, not transient \u2014 do not retry it.", + "Lift the caps with claim then claim_verify. Your key, inboxes and history survive.", + "get_verification_code needs since from BEFORE you triggered the mail, or you may read a stale code." + ], + "next": [ + "io.pilot.deadsimple deadsimple.help '{}'" + ] + }, + "next_steps": { + "schema": 1, + "app": "io.pilot.deadsimple", + "edges": [ + { + "from": "*", + "on": "err", + "code": 401, + "why": "no API key is configured yet, so the account has to be created before anything else will authenticate", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.deadsimple deadsimple.signup '{\"label\":\"my-agent\"}'", + "why": "one call returns an account, an API key and a live inbox; save the key as DEADSIMPLE_API_KEY", + "kind": "gateway" + } + ] + }, + { + "from": "deadsimple.signup", + "on": "ok", + "why": "you now have a key and an inbox, so the address is ready to hand to whatever needs to mail you", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.deadsimple deadsimple.get_verification_code '{\"inbox_id\":\"\",\"since\":\"\"}'", + "why": "the usual reason to want an inbox: read the one-time code out of the newest inbound mail", + "kind": "flow" + } + ] + }, + { + "from": "deadsimple.get_verification_code", + "on": "ok", + "match": "\"found\"\\s*:\\s*false", + "why": "the mail has not arrived yet, which is expected for the first few seconds after triggering it", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.deadsimple deadsimple.get_verification_code '{\"inbox_id\":\"\",\"since\":\"\"}'", + "why": "poll every 2-3 seconds for up to a minute with the SAME since value; do not widen it or you may read a stale code", + "kind": "recovery" + } + ] + }, + { + "from": "deadsimple.send_email", + "on": "err", + "code": 429, + "why": "the trial send cap is reached; this is a quota signal, so retrying the same call will keep failing", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.deadsimple deadsimple.claim '{\"email\":\"\"}'", + "why": "claim then claim_verify moves the account to the Free plan and keeps your key, inboxes and history", + "kind": "recovery" + } + ] + } + ] + }, + "id": "io.pilot.deadsimple", + "version": "0.1.1", + "namespace": "deadsimple", + "description": "An email identity your agent can provision for itself in one call, then use to send, receive, reply on-thread, and pull one-time signup codes straight out of its own inbox.", + "email": "hello@deadsimple.email", + "backend": { + "base_url": "https://api.deadsimple.email", + "auth": "byo", + "headers": [ + { + "name": "Authorization", + "value": "Bearer ${DEADSIMPLE_API_KEY}" + } + ] + }, + "methods": [ + { + "name": "deadsimple.signup", + "description": "START HERE if you have no key. Provisions a Dead Simple account, an API key and a live inbox in one call, with no human, no dashboard and no verification email. Returns {account_id, api_key, inbox}. Save api_key as the DEADSIMPLE_API_KEY secret \u2014 every other method authenticates with it. Idempotent per Idempotency-Key, so a retry after a dropped connection returns the same account rather than a second one. The account starts on the trial tier: 1 inbox, 10 sends an hour, 25 a day.", + "latency": "med", + "signup": { + "step": "create", + "url": "https://api.deadsimple.email/v1/auth/agent-signup", + "secret_key": "DEADSIMPLE_API_KEY", + "address_path": "data.inbox.email" + }, + "params": [ + { + "name": "label", + "type": "string", + "required": false, + "description": "Optional human-readable label for the account, e.g. the agent or project name.", + "in": "body" + } + ] + }, + { + "name": "deadsimple.claim", + "description": "Start lifting the trial caps by attaching an email address a human controls. Sends a 6-digit code to that address; pass it to deadsimple.claim_verify to finish. Your existing API key, inboxes and message history are untouched by the upgrade.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/v1/auth/claim" + }, + "params": [ + { + "name": "email", + "type": "string", + "required": true, + "description": "An email address a human controls. This is the human owner of record, not one of your own inboxes.", + "in": "body" + } + ] + }, + { + "name": "deadsimple.claim_verify", + "description": "Confirm the code from deadsimple.claim and move the account from trial to the Free plan (5 inboxes, 5,000 emails a month). The same API key keeps working.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/v1/auth/claim/verify" + }, + "params": [ + { + "name": "email", + "type": "string", + "required": true, + "description": "The same address passed to deadsimple.claim.", + "in": "body" + }, + { + "name": "code", + "type": "string", + "required": true, + "description": "The 6-digit code delivered to that address.", + "in": "body" + }, + { + "name": "password", + "type": "string", + "required": false, + "description": "Optional password to set for the human-facing account.", + "in": "body" + } + ] + }, + { + "name": "deadsimple.create_inbox", + "description": "Create a real, deliverable email inbox in one call. Returns an inbox_id and a live address that can send and receive immediately \u2014 no SMTP setup, no DNS, no mailbox provisioning. Use this when you already have a key and want an additional identity.", + "latency": "fast", + "http": { + "verb": "POST", + "path": "/v1/inboxes" + }, + "params": [ + { + "name": "local_part", + "type": "string", + "required": false, + "description": "The part before the @. Omit to have one generated.", + "in": "body" + }, + { + "name": "domain", + "type": "string", + "required": false, + "description": "Custom domain to use instead of deadsimple.email. Paid plans only.", + "in": "body" + }, + { + "name": "display_name", + "type": "string", + "required": false, + "description": "Display name recipients see on outbound mail.", + "in": "body" + }, + { + "name": "tags", + "type": "object", + "required": false, + "description": "Free-form key/value tags for grouping inboxes, e.g. {\"task\":\"signup-flow\"}.", + "in": "body" + } + ] + }, + { + "name": "deadsimple.list_inboxes", + "description": "List the inboxes this key can see, newest first. Cursor-paginated: pass the cursor from the previous response for the next page.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/inboxes" + }, + "params": [ + { + "name": "cursor", + "type": "string", + "required": false, + "description": "Cursor from the previous response.", + "in": "query" + }, + { + "name": "limit", + "type": "int", + "required": false, + "description": "Page size.", + "in": "query" + }, + { + "name": "status", + "type": "string", + "required": false, + "description": "Filter by inbox status.", + "in": "query" + }, + { + "name": "tag", + "type": "string", + "required": false, + "description": "Filter by a tag set at creation.", + "in": "query" + } + ] + }, + { + "name": "deadsimple.get_inbox", + "description": "Fetch one inbox: its address, display name, tags, status, and counters.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/inboxes/{inbox_id}" + }, + "params": [ + { + "name": "inbox_id", + "type": "string", + "required": true, + "description": "The inbox id.", + "in": "path" + } + ] + }, + { + "name": "deadsimple.delete_inbox", + "description": "Permanently delete an inbox and its stored messages. IRREVERSIBLE. Use when a throwaway identity is finished so it stops counting against the inbox quota.", + "latency": "fast", + "http": { + "verb": "DELETE", + "path": "/v1/inboxes/{inbox_id}" + }, + "params": [ + { + "name": "inbox_id", + "type": "string", + "required": true, + "description": "The inbox id to delete.", + "in": "path" + } + ] + }, + { + "name": "deadsimple.send_email", + "description": "Send an email from one of your inboxes. Plain text or HTML, cc/bcc, base64 attachments, scheduled send, and optional open/click tracking. Real DKIM-signed egress, not a test harness. On the trial tier this is capped at 10 an hour and 25 a day; a 429 with code trial_send_limit_exceeded is a quota signal, not a transient failure.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/v1/inboxes/{inbox_id}/messages" + }, + "params": [ + { + "name": "inbox_id", + "type": "string", + "required": true, + "description": "The inbox to send from.", + "in": "path" + }, + { + "name": "to", + "type": "string", + "required": true, + "description": "Recipient address, or a list of them.", + "in": "body" + }, + { + "name": "subject", + "type": "string", + "required": true, + "description": "Subject line.", + "in": "body" + }, + { + "name": "text_body", + "type": "string", + "required": false, + "description": "Plain-text body. Supply this or html_body.", + "in": "body" + }, + { + "name": "html_body", + "type": "string", + "required": false, + "description": "HTML body. Supply this or text_body.", + "in": "body" + }, + { + "name": "cc", + "type": "object", + "required": false, + "description": "CC recipients.", + "in": "body" + }, + { + "name": "bcc", + "type": "object", + "required": false, + "description": "BCC recipients.", + "in": "body" + }, + { + "name": "reply_to", + "type": "string", + "required": false, + "description": "Reply-To address.", + "in": "body" + }, + { + "name": "attachments", + "type": "object", + "required": false, + "description": "Base64-encoded attachments.", + "in": "body" + }, + { + "name": "send_at", + "type": "string", + "required": false, + "description": "ISO timestamp to schedule the send.", + "in": "body" + }, + { + "name": "tracking", + "type": "bool", + "required": false, + "description": "Enable open and click tracking on HTML mail.", + "in": "body" + } + ] + }, + { + "name": "deadsimple.get_verification_code", + "description": "THE method agents reach for. Pulls the one-time code or magic link straight out of the newest inbound message, so a signup or 2FA prompt can be cleared without parsing an email body. Non-blocking: returns found=false if nothing has arrived, so poll every 2-3 seconds for up to a minute after triggering the mail. Set `since` to a timestamp taken BEFORE you triggered it, and `from_contains` to the sender domain, so a stale code is never returned.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/inboxes/{inbox_id}/verification" + }, + "params": [ + { + "name": "inbox_id", + "type": "string", + "required": true, + "description": "The inbox to read.", + "in": "path" + }, + { + "name": "since", + "type": "string", + "required": false, + "description": "ISO timestamp taken BEFORE triggering the mail. Anything older is ignored, which is how stale codes are avoided.", + "in": "query" + }, + { + "name": "from_contains", + "type": "string", + "required": false, + "description": "Only consider mail whose sender contains this substring, e.g. 'stripe.com'.", + "in": "query" + }, + { + "name": "window", + "type": "int", + "required": false, + "description": "How many seconds back to look.", + "in": "query" + } + ] + }, + { + "name": "deadsimple.list_messages", + "description": "List messages in an inbox, newest first, with sender, subject, snippet, and labels. Spam is excluded unless include_spam is true. Cursor-paginated.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/inboxes/{inbox_id}/messages" + }, + "params": [ + { + "name": "inbox_id", + "type": "string", + "required": true, + "description": "The inbox to list.", + "in": "path" + }, + { + "name": "cursor", + "type": "string", + "required": false, + "description": "Cursor from the previous response.", + "in": "query" + }, + { + "name": "limit", + "type": "int", + "required": false, + "description": "Page size.", + "in": "query" + }, + { + "name": "labels", + "type": "string", + "required": false, + "description": "Filter by label.", + "in": "query" + }, + { + "name": "include_spam", + "type": "bool", + "required": false, + "description": "Include spam-classified mail.", + "in": "query" + } + ] + }, + { + "name": "deadsimple.get_message", + "description": "Get one message in full: headers, plain-text and HTML bodies, and attachment metadata.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/inboxes/{inbox_id}/messages/{message_id}" + }, + "params": [ + { + "name": "inbox_id", + "type": "string", + "required": true, + "description": "The inbox the message is in.", + "in": "path" + }, + { + "name": "message_id", + "type": "string", + "required": true, + "description": "The message id.", + "in": "path" + } + ] + }, + { + "name": "deadsimple.reply", + "description": "Reply to the sender of a message. Threading headers are set automatically so the reply lands in the same conversation \u2014 never hand-build In-Reply-To.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/v1/inboxes/{inbox_id}/messages/{message_id}/reply" + }, + "params": [ + { + "name": "inbox_id", + "type": "string", + "required": true, + "description": "The inbox the message is in.", + "in": "path" + }, + { + "name": "message_id", + "type": "string", + "required": true, + "description": "The message being replied to.", + "in": "path" + }, + { + "name": "text_body", + "type": "string", + "required": false, + "description": "Plain-text reply body.", + "in": "body" + }, + { + "name": "html_body", + "type": "string", + "required": false, + "description": "HTML reply body.", + "in": "body" + } + ] + }, + { + "name": "deadsimple.reply_all", + "description": "Reply to the sender and every other recipient of a message, with threading headers set automatically.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/v1/inboxes/{inbox_id}/messages/{message_id}/reply-all" + }, + "params": [ + { + "name": "inbox_id", + "type": "string", + "required": true, + "description": "The inbox the message is in.", + "in": "path" + }, + { + "name": "message_id", + "type": "string", + "required": true, + "description": "The message being replied to.", + "in": "path" + }, + { + "name": "text_body", + "type": "string", + "required": false, + "description": "Plain-text reply body.", + "in": "body" + }, + { + "name": "html_body", + "type": "string", + "required": false, + "description": "HTML reply body.", + "in": "body" + } + ] + }, + { + "name": "deadsimple.forward", + "description": "Forward a message, with its attachments, to new recipients.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/v1/inboxes/{inbox_id}/messages/{message_id}/forward" + }, + "params": [ + { + "name": "inbox_id", + "type": "string", + "required": true, + "description": "The inbox the message is in.", + "in": "path" + }, + { + "name": "message_id", + "type": "string", + "required": true, + "description": "The message to forward.", + "in": "path" + }, + { + "name": "to", + "type": "object", + "required": true, + "description": "Recipients to forward to.", + "in": "body" + }, + { + "name": "text_body", + "type": "string", + "required": false, + "description": "Optional note to prepend.", + "in": "body" + } + ] + }, + { + "name": "deadsimple.list_threads", + "description": "List conversation threads in an inbox, each with its latest message, so an agent can track ongoing exchanges instead of loose messages.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/inboxes/{inbox_id}/threads" + }, + "params": [ + { + "name": "inbox_id", + "type": "string", + "required": true, + "description": "The inbox to list threads for.", + "in": "path" + }, + { + "name": "limit", + "type": "int", + "required": false, + "description": "Page size.", + "in": "query" + } + ] + }, + { + "name": "deadsimple.get_thread", + "description": "Get a full conversation thread with every message in order \u2014 the context an agent needs before replying.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/inboxes/{inbox_id}/threads/{thread_id}" + }, + "params": [ + { + "name": "inbox_id", + "type": "string", + "required": true, + "description": "The inbox the thread is in.", + "in": "path" + }, + { + "name": "thread_id", + "type": "string", + "required": true, + "description": "The thread id.", + "in": "path" + } + ] + }, + { + "name": "deadsimple.get_attachment", + "description": "Get a time-limited signed download URL for an attachment. The link expires in one hour.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/inboxes/{inbox_id}/messages/{message_id}/attachments/{attachment_id}" + }, + "params": [ + { + "name": "inbox_id", + "type": "string", + "required": true, + "description": "The inbox the message is in.", + "in": "path" + }, + { + "name": "message_id", + "type": "string", + "required": true, + "description": "The message the attachment is on.", + "in": "path" + }, + { + "name": "attachment_id", + "type": "string", + "required": true, + "description": "The attachment id from get_message.", + "in": "path" + } + ] + }, + { + "name": "deadsimple.list_all_messages", + "description": "List messages across every inbox this key can see, newest first. For a supervisor agent watching many identities at once \u2014 cheaper than iterating inboxes.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/messages" + }, + "params": [ + { + "name": "cursor", + "type": "string", + "required": false, + "description": "Cursor from the previous response.", + "in": "query" + }, + { + "name": "limit", + "type": "int", + "required": false, + "description": "Page size.", + "in": "query" + }, + { + "name": "direction", + "type": "string", + "required": false, + "description": "Filter to inbound or outbound mail.", + "in": "query" + } + ] + }, + { + "name": "deadsimple.create_webhook", + "description": "Register an HMAC-SHA256 signed webhook for inbound mail, bounces, and complaints, so long-running work does not have to poll list_messages in a loop. Deliveries retry on exponential backoff and every attempt is logged.", + "latency": "fast", + "http": { + "verb": "POST", + "path": "/v1/webhooks" + }, + "params": [ + { + "name": "url", + "type": "string", + "required": true, + "description": "HTTPS endpoint to deliver to.", + "in": "body" + }, + { + "name": "events", + "type": "object", + "required": true, + "description": "Event types to subscribe to, e.g. [\"message.received\"].", + "in": "body" + }, + { + "name": "description", + "type": "string", + "required": false, + "description": "Free-form note about what this webhook is for.", + "in": "body" + } + ] + } + ], + "listing": { + "display_name": "Dead Simple Email", + "tagline": "An email identity your agent provisions for itself, then uses to send, receive, and read its own signup codes.", + "app_description": "Dead Simple Email gives an agent a real, deliverable email identity \u2014 not a sandbox \u2014 and it can get one entirely on its own. A single call to `deadsimple.signup` returns an account, an API key and a live inbox. No dashboard, no verification email, no human in the loop.\n\n**The method agents reach for most is `deadsimple.get_verification_code`.** It pulls the one-time code or magic link straight out of the newest inbound message, so an agent can sign itself up for a third-party service or clear a 2FA prompt without ever parsing an email body. Filter by sender and by timestamp so a stale code is never returned. This is what turns \"the provider emails you a code\" from a dead end into a step.\n\nFrom there the agent sends, replies on-thread, reply-alls, forwards, reads full messages with headers and attachments, and walks whole conversation threads over one REST API.\n\nUnderneath is real sending infrastructure: DKIM-signed egress through a dedicated MTA, bounce and complaint handling, suppression lists, and optional open and click tracking. Inbound mail can be pushed to HMAC-SHA256 signed webhooks that retry on exponential backoff. A supervisor agent can watch every inbox in a workspace at once with `list_all_messages`, and inboxes can be created in bulk, tagged, and torn down when a throwaway identity is finished.\n\n**Tiers.** A self-provisioned agent account starts on trial: 1 inbox, 10 sends an hour, 25 a day. Calling `deadsimple.claim` with an email a human controls, then `deadsimple.claim_verify`, moves it to the Free plan \u2014 5 inboxes and 5,000 emails a month \u2014 keeping the same API key, inboxes and history. Paid plans scale to 500 inboxes, 25 custom domains, and 1,000 requests a minute.\n\n**Edge cases.** A 429 with code `trial_send_limit_exceeded` is a quota signal, not a transient failure \u2014 do not blindly retry, claim the account instead. Pagination is cursor-based. Spam is excluded from `list_messages` unless `include_spam` is true. Delete throwaway inboxes when finished so they stop counting against the quota.", + "license": "Proprietary", + "homepage": "https://deadsimple.email", + "source_url": "https://deadsimple.email", + "categories": [ + "communications", + "email" + ], + "keywords": [ + "email", + "inbox", + "otp", + "verification code", + "magic link", + "agent onboarding", + "transactional email", + "threads", + "attachments", + "webhooks" + ] + }, + "vendor": { + "name": "Dead Simple Email", + "url": "https://deadsimple.email", + "contact": "hello@deadsimple.email", + "agent_usage": "An agent with no key calls deadsimple.signup, which returns an account, an API key and a live inbox in one call with no human step, and caches the key as DEADSIMPLE_API_KEY. The usual next move is to hand the inbox address to a third-party service, note the current time, and poll deadsimple.get_verification_code with `since` set to that timestamp until found=true, which yields the one-time code or magic link without parsing any email. Beyond onboarding, the agent sends with send_email, reads with list_messages and get_message, walks conversations with get_thread, and answers with reply or reply_all, which set threading headers automatically. Trial caps (1 inbox, 10 sends/hour, 25/day) surface as a 429 with code trial_send_limit_exceeded, which is a quota signal rather than a transient error; deadsimple.claim plus claim_verify lifts them to the Free plan while keeping the same key and history.", + "capabilities": "signup: provision an account, API key and inbox in one call, with no human, no dashboard and no verification email.\nget_verification_code: extract a one-time code or magic link from the newest inbound mail, filtered by sender and timestamp so a stale code is never returned.\nsend_email: DKIM-signed sending with plain text or HTML, cc/bcc, base64 attachments, scheduled send, and open/click tracking.\nInbox lifecycle: create, list, inspect and delete real deliverable inboxes, with tags for grouping.\nReading: list messages with sender, subject, snippet and labels; fetch a full message with headers, bodies and attachment metadata; download attachments via a signed one-hour URL.\nConversations: list and read whole threads, then reply, reply-all or forward with threading handled automatically.\nSupervision: list messages across every inbox at once, for an agent watching many identities.\nWebhooks: HMAC-signed delivery for inbound mail, bounces and complaints, with retry and replay.\nPlan upgrade: claim and claim_verify move a trial account to the Free plan without changing the API key." + } +} diff --git a/submissions/io.pilot.dial/submission.json b/submissions/io.pilot.dial/submission.json new file mode 100644 index 0000000..d37f0bb --- /dev/null +++ b/submissions/io.pilot.dial/submission.json @@ -0,0 +1,803 @@ +{ + "id": "io.pilot.dial", + "version": "0.2.0", + "namespace": "dial", + "description": "Give your agent a real US phone number: AI voice calls, SMS and iMessage, with $5 of credit and no credit card.", + "email": "gal@getdial.ai", + "backend": { + "type": "http", + "base_url": "https://api.getdial.ai/v1", + "auth": "byo", + "headers": [ + { + "name": "Authorization", + "value": "Bearer ${DIAL_API_KEY}" + } + ] + }, + "methods": [ + { + "name": "dial.set_key", + "latency": "fast", + "signup": { + "step": "set_key", + "url": "https://getdial.ai/dashboard", + "secret_key": "DIAL_API_KEY" + }, + "description": "Already have a Dial API key (sk_live_...)? Save it on this host; it is sent on every call and calling again replaces it. Local, free.", + "params": [ + { + "name": "api_key", + "type": "string", + "required": true, + "description": "Your sk_live_... key." + } + ] + }, + { + "name": "dial.status", + "description": "Your account: credit balance, plan, and the limits currently in force (max call duration, max concurrent calls). Free. Call this first to orient.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/account" + }, + "params": [] + }, + { + "name": "dial.usage", + "description": "Activity analytics: message and call counts, voice minutes, per-day series, and busiest numbers. Free.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/usage" + }, + "params": [ + { + "name": "days", + "type": "int", + "required": false, + "description": "How many days back to summarize (default 30).", + "in": "query" + } + ] + }, + { + "name": "dial.signup", + "description": "Start creating (or signing in to) a Dial account. Emails a 6-digit code and returns a verificationId; pass both to dial.verify. Free.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/auth/signup", + "public": true + }, + "params": [ + { + "name": "email", + "type": "string", + "required": true, + "description": "Email address to create the account under; the 6-digit verification code is sent to it.", + "in": "body" + } + ] + }, + { + "name": "dial.verify", + "description": "Redeem the emailed code. Signing in to an existing account: the API key is issued and saved on this host at once. New account: the email is verified and a registrationId comes back — Dial also requires a verified phone number that a human owns, so continue with dial.register_number. Free.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/auth/verify", + "public": true, + "save_key": { + "path": "apiKey", + "secret_key": "DIAL_API_KEY", + "start": "dial.signup" + } + }, + "params": [ + { + "name": "verificationId", + "type": "string", + "required": true, + "description": "The id returned by dial.signup.", + "in": "body" + }, + { + "name": "code", + "type": "string", + "required": true, + "description": "The 6-digit code emailed by dial.signup.", + "in": "body" + } + ] + }, + { + "name": "dial.register_number", + "latency": "med", + "http": { + "verb": "POST", + "path": "/auth/register-number", + "public": true + }, + "description": "New accounts only: text a 6-digit code to the phone number that will own the account. Ask your human for a number they keep that can receive SMS — never invent one, and a Dial number is refused. A verified number is bound to one Dial account permanently. Free.", + "params": [ + { + "name": "registrationId", + "type": "string", + "required": true, + "description": "From dial.verify.", + "in": "body" + }, + { + "name": "phoneNumber", + "type": "string", + "required": true, + "description": "The owner's phone number, E.164 preferred (e.g. +14155550123).", + "in": "body" + } + ] + }, + { + "name": "dial.verify_number", + "latency": "med", + "http": { + "verb": "POST", + "path": "/auth/verify-number", + "public": true, + "save_key": { + "path": "apiKey", + "secret_key": "DIAL_API_KEY", + "start": "dial.signup" + } + }, + "description": "New accounts only: submit the SMS code. This creates the account (with $5 of credit and a first number) and the API key is saved on this host automatically — every other dial.* method then works. Free.", + "params": [ + { + "name": "registrationId", + "type": "string", + "required": true, + "description": "From dial.verify.", + "in": "body" + }, + { + "name": "code", + "type": "string", + "required": true, + "description": "The 6-digit code from the SMS.", + "in": "body" + } + ] + }, + { + "name": "dial.list_numbers", + "description": "Your phone numbers, with capabilities, setup status, and per-number inbound configuration. Free.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/numbers" + }, + "params": [] + }, + { + "name": "dial.buy_number", + "description": "Provision a new US phone number. COSTS $3/month. Requires an explicit consent attestation because it spends money on behalf of the account holder, so confirm with your human first.", + "latency": "slow", + "http": { + "verb": "POST", + "path": "/numbers" + }, + "params": [ + { + "name": "explicitProgrammaticConsent", + "type": "string", + "required": true, + "description": "Short human-readable attestation that the account holder consented to provisioning this number programmatically. Stored for the audit trail.", + "in": "body" + }, + { + "name": "capabilities", + "type": "object", + "required": false, + "description": "Channels the number should support: [\"sms\",\"call\"] (default), or add \"imessage\" for an iMessage number (provisioned asynchronously).", + "in": "body" + }, + { + "name": "inboundInstruction", + "type": "string", + "required": false, + "description": "System prompt the AI uses on inbound calls to this number; a sensible default is used if omitted.", + "in": "body" + }, + { + "name": "areaCode", + "type": "string", + "required": false, + "description": "Preferred US area code; omitted means any available US number. Ignored for iMessage numbers.", + "in": "body" + } + ] + }, + { + "name": "dial.get_number", + "description": "One phone number by id. Free.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/numbers/{id}" + }, + "params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "The number's Dial id.", + "in": "path" + } + ] + }, + { + "name": "dial.set_number", + "description": "Update a number: nickname, inbound behavior, voice, language, call ceiling, and (iMessage numbers only) the display name and avatar recipients see. Only the fields you send change. Free.", + "latency": "fast", + "http": { + "verb": "PATCH", + "path": "/numbers/{id}" + }, + "params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "The number's Dial id.", + "in": "path" + }, + { + "name": "nickname", + "type": "string", + "required": false, + "description": "Human-readable label, e.g. \"Support line\". Empty string clears it.", + "in": "body" + }, + { + "name": "inboundInstruction", + "type": "string", + "required": false, + "description": "New system prompt for inbound calls to this number.", + "in": "body" + }, + { + "name": "inboundVoiceGender", + "type": "string", + "required": false, + "description": "Voice gender for inbound calls: \"male\" or \"female\". Omitted leaves it unchanged.", + "in": "body" + }, + { + "name": "maxCallDurationSeconds", + "type": "int", + "required": false, + "description": "Per-call ceiling for this number, in seconds (60-3600).", + "in": "body" + } + ] + }, + { + "name": "dial.release_number", + "description": "Release a number. IRREVERSIBLE: the number is gone and the unused month is not refunded. Confirm with your human first.", + "latency": "med", + "http": { + "verb": "DELETE", + "path": "/numbers/{id}" + }, + "params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "The number's Dial id.", + "in": "path" + } + ] + }, + { + "name": "dial.send_message", + "description": "Send a text. Delivers over iMessage when the recipient supports it, otherwise SMS, same call either way. COSTS MONEY (US SMS $0.02 per segment). Pass exactly one of fromNumber or fromNumberId.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/messages" + }, + "params": [ + { + "name": "to", + "type": "string", + "required": true, + "description": "Destination phone number, E.164 (e.g. +14155550123).", + "in": "body" + }, + { + "name": "fromNumber", + "type": "string", + "required": false, + "description": "One of your numbers in E.164 to send from.", + "in": "body" + }, + { + "name": "fromNumberId", + "type": "string", + "required": false, + "description": "Number id to send from; defaults to your primary number.", + "in": "body" + }, + { + "name": "body", + "type": "string", + "required": true, + "description": "Message text.", + "in": "body" + } + ] + }, + { + "name": "dial.list_messages", + "description": "Your messages, inbound and outbound, newest first (up to the 100 most recent). Free. Poll this for replies, or prefer dial.wait_for_event to block until one arrives.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/messages" + }, + "params": [ + { + "name": "since", + "type": "string", + "required": false, + "description": "ISO-8601 timestamp; only messages after this.", + "in": "query" + }, + { + "name": "direction", + "type": "string", + "required": false, + "description": "Filter by direction: \"inbound\" or \"outbound\".", + "in": "query" + } + ] + }, + { + "name": "dial.typing", + "description": "Show or clear a typing indicator before you reply. iMessage numbers display it; SMS numbers ignore it, so it is always safe to call. Sending a message clears it automatically. Free.", + "latency": "fast", + "http": { + "verb": "POST", + "path": "/typing" + }, + "params": [ + { + "name": "toNumber", + "type": "string", + "required": true, + "description": "Recipient phone number, E.164.", + "in": "body" + }, + { + "name": "fromNumber", + "type": "string", + "required": true, + "description": "The number the indicator appears from: one of your numbers in E.164, its id, or nickname.", + "in": "body" + }, + { + "name": "value", + "type": "bool", + "required": true, + "description": "true to start the indicator, false to clear it.", + "in": "body" + } + ] + }, + { + "name": "dial.place_call", + "description": "Place an outbound AI voice call. Returns a call id immediately; the call runs in the background. COSTS MONEY per minute. Pass transferTo to have the agent wait out hold queues and IVR menus and hand the live call to a human when a real person answers. Poll dial.get_call, or block on dial.wait_for_event with call.ended.", + "latency": "slow", + "http": { + "verb": "POST", + "path": "/calls" + }, + "params": [ + { + "name": "to", + "type": "string", + "required": true, + "description": "Destination phone number, E.164.", + "in": "body" + }, + { + "name": "outboundInstruction", + "type": "string", + "required": true, + "description": "System prompt for the AI voice agent on this call.", + "in": "body" + }, + { + "name": "transferTo", + "type": "string", + "required": false, + "description": "Number in E.164 to hand the call to a human. The agent rides out hold music and automated menus and cold-transfers the moment a real person answers — never to a recording or an IVR. Must differ from to and the from number.", + "in": "body" + }, + { + "name": "fromNumber", + "type": "string", + "required": false, + "description": "One of your numbers in E.164 to call from.", + "in": "body" + }, + { + "name": "fromNumberId", + "type": "string", + "required": false, + "description": "Number id to call from; defaults to your primary number.", + "in": "body" + }, + { + "name": "maxCallDurationSeconds", + "type": "int", + "required": false, + "description": "Hard ceiling for this call, in seconds (60-3600).", + "in": "body" + } + ] + }, + { + "name": "dial.get_call", + "description": "One call with its status and transcript once available. THE poll target after dial.place_call. Free.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/calls/{id}" + }, + "params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "The call's Dial id.", + "in": "path" + } + ] + }, + { + "name": "dial.list_calls", + "description": "Your calls, newest first (up to the 100 most recent), inbound and outbound. Free.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/calls" + }, + "params": [] + }, + { + "name": "dial.end_call", + "description": "Hang up a call that is still in progress. Free.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/calls/{id}/stop" + }, + "params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "The call's Dial id.", + "in": "path" + } + ] + }, + { + "name": "dial.wait_for_event", + "description": "Block until a matching event arrives instead of polling. Use it for an inbound reply or a 2FA code (message.received) or a finished call (call.ended, call.transcribed). A 408 means the wait timed out: try again, it is not an error. Free.", + "latency": "slow", + "http": { + "verb": "POST", + "path": "/events/wait" + }, + "params": [ + { + "name": "eventType", + "type": "string", + "required": true, + "description": "Event type to wait for, e.g. message.received or call.ended.", + "in": "body" + }, + { + "name": "filters", + "type": "object", + "required": false, + "description": "Exact-match filters on event fields, as name/value pairs.", + "in": "body" + }, + { + "name": "timeout", + "type": "int", + "required": false, + "description": "Seconds to wait before returning empty (max 60).", + "in": "body" + } + ] + }, + { + "name": "dial.list_context_mcps", + "description": "The MCP servers connected to your voice agent. Secrets and tokens are masked. Free.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/context-mcps" + }, + "params": [] + }, + { + "name": "dial.add_context_mcp", + "description": "Connect an MCP server so its tools are available to your AI voice agent DURING calls, to look something up or take an action mid-conversation. Works unattended for servers needing no auth or a static header. An OAuth server instead returns an authorizationUrl and stays pending until a human grants consent in a browser. Free.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/context-mcps" + }, + "params": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "A label for this MCP connection.", + "in": "body" + }, + { + "name": "url", + "type": "string", + "required": true, + "description": "The MCP server's URL.", + "in": "body" + }, + { + "name": "headers", + "type": "object", + "required": false, + "description": "Optional static headers to send to the MCP server (e.g. an API key).", + "in": "body" + } + ] + }, + { + "name": "dial.remove_context_mcp", + "description": "Disconnect an MCP server from your voice agent. Its tools stop being offered on future calls. Free.", + "latency": "fast", + "http": { + "verb": "DELETE", + "path": "/context-mcps/{id}" + }, + "params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "The connection's Dial id.", + "in": "path" + } + ] + } + ], + "vendor": { + "name": "Dial", + "url": "https://getdial.ai", + "contact": "gal@getdial.ai", + "agent_usage": "Fully autonomous once the operator supplies DIAL_API_KEY at install time. No human step is required for the core loop. Typical run: 1. dial.status to confirm credit and the limits in force (free). 2. dial.list_numbers; if empty, dial.buy_number with an explicitProgrammaticConsent attestation ($3/mo, so confirm with the human first). 3. dial.send_message to text someone, or dial.place_call with an outboundInstruction to run an AI voice call. 4. dial.wait_for_event with eventType message.received to block on the reply, or call.ended to block on the call finishing. Then dial.get_call for the transcript. Every method is JSON in, JSON out over REST, and dial.help returns the full method list with parameters and latency classes at runtime, so an agent can discover the surface without prior knowledge. Two places a human is genuinely needed, both avoidable: - dial.signup / dial.verify send a 6-digit code by email, so they only work unattended if the agent can read that inbox. An agent that already has a key never calls them. - dial.add_context_mcp against an OAuth-protected MCP server returns an authorizationUrl and stays pending until someone grants consent in a browser. Servers needing no auth or a static header connect unattended.", + "capabilities": "- Provision a real US phone number, and release it - Send SMS and iMessage, with automatic iMessage-to-SMS fallback - Read inbound and outbound message history - Show an iMessage typing indicator - Place an autonomous AI voice call from a given number - Wait out a hold queue or IVR and hand the live call to a human the moment a real person answers (transferTo) - Read call status and full call transcripts - Hang up an in-progress call - Block until an inbound message, 2FA code, or finished call arrives (long-poll, no webhook needed) - Configure a number: nickname, inbound behavior, voice, language, max call duration - Set the iMessage display name and avatar recipients see - Connect an MCP server so its tools are callable by the voice agent during a live call - Read account status, credit balance, active limits, and usage analytics - Create a Dial account and exchange an emailed code for an API key" + }, + "listing": { + "display_name": "Dial", + "tagline": "A complete phone identity for your agent — voice calls, SMS and iMessage on a real US number, provisioned in one call.", + "app_description": "**Signup.** `dial.signup` → `dial.verify` signs in to an existing account and saves its key. A **new** account also needs a phone number a human owns: `dial.register_number` texts it a code, and `dial.verify_number` creates the account and saves the key. Already have a key? `dial.set_key`.\n\nDial gives your agent its own real phone number: place and receive AI voice calls, send and receive SMS and iMessage, and wait on inbound events without polling. Bring your Dial API key at install time and every method authenticates as your own account. The account is yours, works outside Pilot too, and starts with $5 of credit and no credit card. No key yet? dial.signup emails a 6-digit code and dial.verify exchanges it for one. What you can do - Call people. dial.place_call runs an autonomous AI voice call from your number: book a table, chase a delivery, return a missed call. Pass transferTo and it waits out hold music and IVR menus, then cold-transfers to your human the moment a real person answers — never to a recording. Poll dial.get_call for the transcript, or block on dial.wait_for_event. - Give the call your tools. Connect a Context MCP and your MCP server tools become available to the voice agent DURING the call, so it can look something up or take an action mid-conversation. Dial runs the OAuth 2.1 flow and keeps the token fresh in the background. - Text people. dial.send_message delivers over iMessage when the recipient supports it and falls back to SMS otherwise, same call either way. Show a typing indicator first with dial.typing. - Wait, do not poll. dial.wait_for_event blocks until an inbound reply, a 2FA code, or a finished call shows up. A 408 means try again, not an error. - Own the identity. Set each number inbound behavior, voice and language, and on iMessage numbers the display name and avatar recipients see in Messages. Cost Reads are free. $3/month per number. US SMS $0.02 per segment. Managed voice $0.22/min all-in; self-hosted voice $0.13/min if you bring your own LLM over a WebSocket. Your $5 signup credit covers the first usage and spend pauses at $0 rather than surprising you. Good to know A brand-new account is on the free tier until it is topped up, which caps calls at 5 minutes and 2 concurrent calls. Both lift permanently on the first top-up. Emergency and crisis numbers are blocked. Always pass numbers in E.164, e.g. +14155551234. Every method, its parameters and latency class are discoverable at runtime via dial.help.", + "license": "MIT", + "homepage": "https://getdial.ai", + "source_url": "https://github.com/GetDial-AI/cli", + "categories": [ + "communications", + "phone", + "voice", + "messaging" + ], + "keywords": [ + "phone", + "sms", + "imessage", + "voice", + "calls", + "telephony", + "agent", + "mcp", + "webhooks" + ] + }, + "product_demo": { + "skill": "io.pilot.dial", + "title": "Full usage demo", + "metered": true, + "when_to_use": "When a task needs a phone: call a business and sit through the hold queue, text someone and wait for the reply, or receive a 2FA code on a real US number.", + "quickstart": { + "goal": "See your credit and the limits in force before spending anything", + "command": "pilotctl appstore call io.pilot.dial dial.status '{}'", + "expect": "{\"credit\":\"$5.00\",\"plan\":\"free\",\"maxCallDurationSeconds\":300,\"maxConcurrentCalls\":2}", + "cost": "$0.00 (read)", + "note": "Free. A new account starts with $5 and no card. No key yet? dial.signup emails a code; dial.verify signs you in (existing account) or, for a new one, dial.register_number + dial.verify_number verify a phone your human owns. The key is saved on this host automatically." + }, + "examples": [ + { + "title": "Get a number, then text someone", + "goal": "A number is the prerequisite for everything else", + "command": "pilotctl appstore call io.pilot.dial dial.buy_number '{\"areaCode\":\"415\",\"explicitProgrammaticConsent\":\"yes\"}'", + "expect": "{\"id\":\"num_...\",\"phoneNumber\":\"+14155550123\"}", + "cost": "$3.00/month", + "note": "Spends money, so it requires an explicit consent attestation — confirm with your human first. Then dial.send_message delivers over iMessage when the recipient supports it, else SMS." + }, + { + "title": "Place a call and let it wait out the IVR", + "goal": "The thing an agent genuinely cannot do itself", + "command": "pilotctl appstore call io.pilot.dial dial.place_call '{\"to\":\"+14155550199\",\"fromNumber\":\"+14155550123\",\"outboundInstruction\":\"Ask if they take reservations for 4 at 8pm Friday\",\"transferTo\":\"+14155550100\"}'", + "expect": "{\"id\":\"call_...\",\"status\":\"queued\"}", + "cost": "$0.22/min", + "note": "transferTo hands the live call to your human the moment a real person answers — never to a recording. Poll dial.get_call for the transcript." + }, + { + "title": "Block until the reply arrives, instead of polling", + "command": "pilotctl appstore call io.pilot.dial dial.wait_for_event '{\"eventType\":\"message.received\",\"timeout\":120}'", + "expect": "{\"event\":\"message.received\",\"message\":{\"body\":\"Your code is 481920\"}}", + "cost": "$0.00 (read)", + "note": "A 408 means try again, not an error. This is how you catch a 2FA code without spinning." + } + ], + "cost": { + "unit": "USD", + "free_budget": "$5.00 signup credit, no card", + "hard_cap_usd": 5.0, + "operations": [ + { + "op": "reads (status, usage, list_*, get_call, wait_for_event)", + "price": "free" + }, + { + "op": "dial.buy_number", + "price": "$3.00/month", + "note": "requires an explicit consent attestation" + }, + { + "op": "dial.send_message", + "price": "$0.02 per SMS segment", + "note": "iMessage delivery where supported" + }, + { + "op": "dial.place_call", + "price": "$0.22/min", + "note": "$0.13/min self-hosted if you bring your own LLM" + } + ], + "worked_total": "$3.44 — one number for a month ($3.00), one 2-minute call ($0.44), reads free. Inside the $5 signup credit." + }, + "gotchas": [ + "New accounts need a real phone number your human owns (register_number + verify_number). Ask for it; never invent one.", + "Reads are free; buying a number, texting and calling cost money. Check dial.status first.", + "dial.buy_number needs an explicit consent attestation because it spends — confirm with your human.", + "A new account is capped at 5-minute calls and 2 concurrent until the first top-up.", + "Always pass numbers in E.164, e.g. +14155551234. Emergency and crisis numbers are blocked." + ], + "next": [ + "io.pilot.dial dial.help '{}'" + ] + }, + "next_steps": { + "schema": 1, + "app": "io.pilot.dial", + "edges": [ + { + "from": "*", + "on": "ok", + "why": "no Dial API key on this host yet, so nothing else will authenticate", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.dial dial.signup '{\"email\":\"you@example.com\"}'", + "why": "emails a 6-digit code and returns a verificationId for dial.verify; already have a key? dial.set_key", + "kind": "gateway" + } + ], + "match": "\"needs_signup\"\\s*:\\s*true" + }, + { + "from": "dial.status", + "on": "ok", + "why": "you know the credit and the limits in force, so the next thing an agent needs is a number to act from", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.dial dial.list_numbers '{}'", + "why": "if this is empty you need dial.buy_number, which costs $3/month and needs explicit consent", + "kind": "flow" + } + ] + }, + { + "from": "dial.place_call", + "on": "ok", + "why": "the call runs in the background, so the id is a handle rather than a result", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.dial dial.wait_for_event '{\"eventType\":\"call.ended\",\"timeout\":300}'", + "why": "blocks until the call finishes instead of spinning on get_call; a 408 means try again", + "kind": "flow" + } + ] + }, + { + "from": "dial.send_message", + "on": "ok", + "why": "a sent text is only half a conversation; the reply is what the agent is usually waiting for", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.dial dial.wait_for_event '{\"eventType\":\"message.received\",\"timeout\":120}'", + "why": "long-polls for the inbound reply or 2FA code rather than polling list_messages", + "kind": "flow" + } + ] + }, + { + "from": "dial.buy_number", + "on": "err", + "code": 400, + "why": "buying a number spends money, so it is refused without an explicit consent attestation", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.dial dial.buy_number '{\"areaCode\":\"415\",\"explicitProgrammaticConsent\":\"yes\"}'", + "why": "confirm the $3/month charge with your human first, then pass explicitProgrammaticConsent", + "kind": "recovery" + } + ] + }, + { + "from": "dial.verify", + "on": "ok", + "match": "\"registrationId\"", + "why": "the email is verified but new Dial accounts also need a phone number a human owns", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.dial dial.register_number '{\"registrationId\":\"\",\"phoneNumber\":\"\"}'", + "why": "ask your human for a number they keep that receives SMS", + "kind": "gateway" + } + ] + }, + { + "from": "dial.register_number", + "on": "ok", + "why": "a 6-digit code was texted to that number", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.dial dial.verify_number '{\"registrationId\":\"\",\"code\":\"\"}'", + "why": "creates the account and saves the key on this host", + "kind": "flow" + } + ] + }, + { + "from": "dial.verify_number", + "on": "ok", + "why": "the account exists and its key is saved", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.dial dial.status '{}'", + "why": "see your $5 credit and limits before spending", + "kind": "flow" + } + ] + } + ] + } +} diff --git a/submissions/io.pilot.docker/submission.json b/submissions/io.pilot.docker/submission.json index ade4111..32615f9 100644 --- a/submissions/io.pilot.docker/submission.json +++ b/submissions/io.pilot.docker/submission.json @@ -1,7 +1,7 @@ { "id": "io.pilot.docker", - "version": "29.6.1", - "description": "Docker 29.6.1 as a native CLI for agents (Linux): delivers the Docker Engine (dockerd + containerd + runc) and the docker CLI, sha-pinned. Start a local engine (docker.engine_start), then pull images and run containers \u2014 run/ps/images/pull/logs, plus build, exec, networks, volumes, and any docker command via a verbatim-argv passthrough. Real containers on a real Linux host, no Docker Desktop.", + "version": "29.8.1", + "description": "Docker 29.8.1 as a native CLI for agents. On Linux it delivers the Docker Engine (dockerd + containerd + runc) and the docker CLI, sha-pinned. Start a local engine (docker.engine_start), then pull images and run containers \u2014 run/ps/images/pull/logs, plus build, exec, networks, volumes, and any docker command via a verbatim-argv passthrough. Real containers on a real Linux host, no Docker Desktop. On macOS it ships the docker CLI and drives the engine you already run (Docker Desktop, OrbStack, colima).", "email": "apps@pilotprotocol.network", "backend": { "type": "cli", @@ -21,7 +21,7 @@ "methods": [ { "name": "docker.engine_start", - "description": "Start a local Docker Engine (dockerd) on this Linux host, daemonized, on a private unix socket under `DOCKER_DIR` (default /tmp/pilot-docker); waits until the API is ready. Subsequent docker methods auto-target this socket. Requires root (dockerd manages namespaces/cgroups). This is the bundled `dockerd` with `--data-root/--exec-root` under DOCKER_DIR.", + "description": "Make a Docker engine available. If one is already running \u2014 a system or rootless Docker on Linux; Docker Desktop, OrbStack or colima on macOS; or DOCKER_HOST \u2014 it is used as-is and reported. Otherwise, on Linux, it starts the bundled dockerd on a private unix socket under `DOCKER_DIR` (default /tmp/pilot-docker) and waits until the API is ready; that needs root, and without root it says how to get an engine. macOS has no local dockerd, so there it only reports the engine it found or how to start one. Subsequent docker methods target the same engine.", "latency": "slow", "cli": { "args": [ @@ -31,7 +31,7 @@ }, { "name": "docker.engine_stop", - "description": "Stop the local dockerd started by docker.engine_start.", + "description": "Stop the local dockerd started by docker.engine_start. Only stops a dockerd this app started; a system, rootless or desktop engine is left running.", "latency": "med", "cli": { "args": [ @@ -173,8 +173,8 @@ ], "listing": { "display_name": "Docker", - "tagline": "Run Docker from an agent \u2014 a local Docker Engine + CLI on Linux, real containers", - "app_description": "# Docker (Engine + CLI) \u2014 native CLI for agents (Linux)\n\nThis app installs the official **Docker 29.6.1** static distribution on a Linux host and fronts it as typed\nmethods. The bundle carries the full **Docker Engine** \u2014 `dockerd`, `containerd`, `runc`, `containerd-shim-runc-v2`,\n`docker-proxy`, `docker-init`, `ctr` \u2014 plus the `docker` CLI, each sha-pinned and staged at install. A small\n`dockerctl` wrapper manages the engine lifecycle and fronts the CLI.\n\n## Linux only\n\nDocker containers require Linux kernel features (namespaces, cgroups, overlayfs) \u2014 there is **no native macOS\n`dockerd`** (Docker Desktop runs the engine inside a hidden Linux VM). This app therefore targets **Linux (amd64 +\narm64)**. `docker.engine_start` runs a real daemon and requires **root** (the pilot host must run as root, e.g. in a\ncontainer or VM). To use Docker against an existing daemon instead, set `DOCKER_HOST` and skip `engine_start`.\n\n## The usual flow\n\n1. **Start the engine:** `docker.engine_start` `{}` \u2014 boots `dockerd` on a private socket under `DOCKER_DIR`\n (default `/tmp/pilot-docker`), waits until the API is ready.\n2. **Pull + run:** `docker.pull` `{ \"image\": \"hello-world\" }`, then `docker.run` `{ \"image\": \"hello-world\" }`.\n3. **Inspect:** `docker.ps`, `docker.images`, `docker.logs`, `docker.info`.\n4. **Anything else:** `docker.exec` `{ \"args\": [\"run\",\"-d\",\"-p\",\"8080:80\",\"nginx\"] }` \u2014 any docker command with any flags.\n5. **Stop:** `docker.engine_stop`.\n\n## Methods\n\n- `docker.engine_start` / `docker.engine_stop` \u2014 local Docker Engine lifecycle (root).\n- `docker.version`, `docker.info` \u2014 client/server versions and system info.\n- `docker.ps`, `docker.images`, `docker.logs` \u2014 inspect containers/images/logs.\n- `docker.pull` / `docker.run` \u2014 pull an image / run a container (`--rm`).\n- `docker.exec` \u2014 the docker CLI with a verbatim argv (+ optional stdin): run with any flags, `build`, `exec`,\n networks, volumes, compose plugins, etc.\n- `docker.cli_help` \u2014 the full `docker --help`. `docker.help` \u2014 the self-describing method list.\n\n## Configuration\n\n- **`DOCKER_DIR`** (env) \u2014 where dockerd keeps its socket, data-root, exec-root, pidfile, and log\n (default `/tmp/pilot-docker`).\n- **`DOCKER_HOST`** (env) \u2014 point the CLI at an existing/remote daemon instead of the bundled one\n (`tcp://host:2375` or `unix:///path`); when set, skip `docker.engine_start`.\n- **Root** \u2014 `dockerd` needs root and kernel container support. Works on a Linux host/VM/privileged container where\n the pilot daemon runs as root; not on a restricted, capability-stripped sandbox.\n- **Storage driver** \u2014 defaults to `overlay2`; pass an alternative via `docker.exec`\n (`{\"args\":[\"engine-start\",\"--storage-driver\",\"vfs\"]}`) on filesystems where overlay2 is unavailable.\n\n## Good to know\n\n- Free and open source (Apache-2.0). Binaries are the official Docker static release, repackaged unmodified.\n- Output returns verbatim; on a non-zero exit the reply is `{stdout, stderr, exit}`.\n\n## docker --help\n```\nDocker CLI \u2014 commands and options\n=================================\n\nDocker runs applications in containers. This app delivers the Docker Engine\n(dockerd + containerd + runc) and the docker CLI. Start a local engine with\n'engine-start', then use any docker command.\n\nUsage: docker [OPTIONS] COMMAND\n\nA self-sufficient runtime for containers\n\nCommon Commands:\n run Create and run a new container from an image\n exec Execute a command in a running container\n ps List containers\n build Build an image from a Dockerfile\n pull Download an image from a registry\n push Upload an image to a registry\n images List images\n login Authenticate to a registry\n logout Log out from a registry\n search Search Docker Hub for images\n version Show the Docker version information\n info Display system-wide information\n\nManagement Commands:\n builder Manage builds\n compose* Docker Compose\n container Manage containers\n context Manage contexts\n image Manage images\n manifest Manage Docker image manifests and manifest lists\n network Manage networks\n plugin Manage plugins\n system Manage Docker\n volume Manage volumes\n\nSwarm Commands:\n swarm Manage Swarm\n\nCommands:\n attach Attach local standard input, output, and error streams to a running container\n commit Create a new image from a container's changes\n cp Copy files/folders between a container and the local filesystem\n create Create a new container\n diff Inspect changes to files or directories on a container's filesystem\n events Get real time events from the server\n export Export a container's filesystem as a tar archive\n history Show the history of an image\n import Import the contents from a tarball to create a filesystem image\n inspect Return low-level information on Docker objects\n kill Kill one or more running containers\n load Load an image from a tar archive or STDIN\n logs Fetch the logs of a container\n pause Pause all processes within one or more containers\n port List port mappings or a specific mapping for the container\n rename Rename a container\n restart Restart one or more containers\n rm Remove one or more containers\n rmi Remove one or more images\n save Save one or more images to a tar archive (streamed to STDOUT by default)\n start Start one or more stopped containers\n stats Display a live stream of container(s) resource usage statistics\n stop Stop one or more running containers\n tag Create a tag TARGET_IMAGE that refers to SOURCE_IMAGE\n top Display the running processes of a container\n unpause Unpause all processes within one or more containers\n update Update configuration of one or more containers\n wait Block until one or more containers stop, then print their exit codes\n\nGlobal Options:\n --config string Location of client config files (default\n \"/Users/alexgodo/.docker\")\n -c, --context string Name of the context to use to connect to the\n daemon (overrides DOCKER_HOST env var and\n default context set with \"docker context use\")\n -D, --debug Enable debug mode\n -H, --host string Daemon socket to connect to\n -l, --log-level string Set the logging level (\"debug\", \"info\",\n \"warn\", \"error\", \"fatal\") (default \"info\")\n --tls Use TLS; implied by --tlsverify\n --tlscacert string Trust certs signed only by this CA (default\n \"/Users/alexgodo/.docker/ca.pem\")\n --tlscert string Path to TLS certificate file (default\n \"/Users/alexgodo/.docker/cert.pem\")\n --tlskey string Path to TLS key file (default\n \"/Users/alexgodo/.docker/key.pem\")\n --tlsverify Use TLS and verify the remote\n -v, --version Print version information and quit\n\nRun 'docker COMMAND --help' for more information on a command.\n\nFor more help on how to use Docker, head to https://docs.docker.com/go/guides/\n```\n", + "tagline": "Run Docker from an agent \u2014 a local Docker Engine on Linux, or your existing engine on macOS", + "app_description": "# Docker (Engine + CLI) \u2014 native CLI for agents (Linux + macOS)\n\nThis app installs the official **Docker 29.8.1** static distribution and fronts it as typed\nmethods. The bundle carries the full **Docker Engine** \u2014 `dockerd`, `containerd`, `runc`, `containerd-shim-runc-v2`,\n`docker-proxy`, `docker-init`, `ctr` \u2014 plus the `docker` CLI, each sha-pinned and staged at install. A small\n`dockerctl` wrapper manages the engine lifecycle and fronts the CLI.\n\n## Linux and macOS\n\n`docker.engine_start` first looks for an engine that is already running and uses it as-is: a system or rootless\nDocker on Linux; Docker Desktop, OrbStack, colima or Rancher Desktop on macOS; or whatever `DOCKER_HOST` points at.\nOnly if there is none does the **Linux (amd64 + arm64)** bundle start its own engine (`dockerd` + `containerd` + `runc`),\nwhich needs **root**. Docker containers need Linux kernel features, so there is **no native macOS `dockerd`**: the\n**macOS (arm64 + amd64)** bundle ships only the official static `docker` CLI and tells you how to start an engine if none\nis running. `docker.engine_stop` only stops an engine this app started.\n\n## The usual flow\n\n1. **Start the engine:** `docker.engine_start` `{}` \u2014 boots `dockerd` on a private socket under `DOCKER_DIR`\n (default `/tmp/pilot-docker`), waits until the API is ready.\n2. **Pull + run:** `docker.pull` `{ \"image\": \"hello-world\" }`, then `docker.run` `{ \"image\": \"hello-world\" }`.\n3. **Inspect:** `docker.ps`, `docker.images`, `docker.logs`, `docker.info`.\n4. **Anything else:** `docker.exec` `{ \"args\": [\"run\",\"-d\",\"-p\",\"8080:80\",\"nginx\"] }` \u2014 any docker command with any flags.\n5. **Stop:** `docker.engine_stop`.\n\n## Methods\n\n- `docker.engine_start` / `docker.engine_stop` \u2014 local Docker Engine lifecycle (root).\n- `docker.version`, `docker.info` \u2014 client/server versions and system info.\n- `docker.ps`, `docker.images`, `docker.logs` \u2014 inspect containers/images/logs.\n- `docker.pull` / `docker.run` \u2014 pull an image / run a container (`--rm`).\n- `docker.exec` \u2014 the docker CLI with a verbatim argv (+ optional stdin): run with any flags, `build`, `exec`,\n networks, volumes, compose plugins, etc.\n- `docker.cli_help` \u2014 the full `docker --help`. `docker.help` \u2014 the self-describing method list.\n\n## Configuration\n\n- **`DOCKER_DIR`** (env) \u2014 where dockerd keeps its socket, data-root, exec-root, pidfile, and log\n (default `/tmp/pilot-docker`).\n- **`DOCKER_HOST`** (env) \u2014 point the CLI at an existing/remote daemon instead of the bundled one\n (`tcp://host:2375` or `unix:///path`); when set, skip `docker.engine_start`.\n- **Root** \u2014 `dockerd` needs root and kernel container support. Works on a Linux host/VM/privileged container where\n the pilot daemon runs as root; not on a restricted, capability-stripped sandbox.\n- **Storage driver** \u2014 defaults to `overlay2`; pass an alternative via `docker.exec`\n (`{\"args\":[\"engine-start\",\"--storage-driver\",\"vfs\"]}`) on filesystems where overlay2 is unavailable.\n\n## Good to know\n\n- Free and open source (Apache-2.0). Binaries are the official Docker static release, repackaged unmodified.\n- Output returns verbatim; on a non-zero exit the reply is `{stdout, stderr, exit}`.\n\n## docker --help\n```\nDocker CLI \u2014 commands and options\n=================================\n\nDocker runs applications in containers. This app delivers the Docker Engine\n(dockerd + containerd + runc) and the docker CLI. Start a local engine with\n'engine-start', then use any docker command.\n\nUsage: docker [OPTIONS] COMMAND\n\nA self-sufficient runtime for containers\n\nCommon Commands:\n run Create and run a new container from an image\n exec Execute a command in a running container\n ps List containers\n build Build an image from a Dockerfile\n pull Download an image from a registry\n push Upload an image to a registry\n images List images\n login Authenticate to a registry\n logout Log out from a registry\n search Search Docker Hub for images\n version Show the Docker version information\n info Display system-wide information\n\nManagement Commands:\n builder Manage builds\n compose* Docker Compose\n container Manage containers\n context Manage contexts\n image Manage images\n manifest Manage Docker image manifests and manifest lists\n network Manage networks\n plugin Manage plugins\n system Manage Docker\n volume Manage volumes\n\nSwarm Commands:\n swarm Manage Swarm\n\nCommands:\n attach Attach local standard input, output, and error streams to a running container\n commit Create a new image from a container's changes\n cp Copy files/folders between a container and the local filesystem\n create Create a new container\n diff Inspect changes to files or directories on a container's filesystem\n events Get real time events from the server\n export Export a container's filesystem as a tar archive\n history Show the history of an image\n import Import the contents from a tarball to create a filesystem image\n inspect Return low-level information on Docker objects\n kill Kill one or more running containers\n load Load an image from a tar archive or STDIN\n logs Fetch the logs of a container\n pause Pause all processes within one or more containers\n port List port mappings or a specific mapping for the container\n rename Rename a container\n restart Restart one or more containers\n rm Remove one or more containers\n rmi Remove one or more images\n save Save one or more images to a tar archive (streamed to STDOUT by default)\n start Start one or more stopped containers\n stats Display a live stream of container(s) resource usage statistics\n stop Stop one or more running containers\n tag Create a tag TARGET_IMAGE that refers to SOURCE_IMAGE\n top Display the running processes of a container\n unpause Unpause all processes within one or more containers\n update Update configuration of one or more containers\n wait Block until one or more containers stop, then print their exit codes\n\nGlobal Options:\n --config string Location of client config files (default\n \"~/.docker\")\n -c, --context string Name of the context to use to connect to the\n daemon (overrides DOCKER_HOST env var and\n default context set with \"docker context use\")\n -D, --debug Enable debug mode\n -H, --host string Daemon socket to connect to\n -l, --log-level string Set the logging level (\"debug\", \"info\",\n \"warn\", \"error\", \"fatal\") (default \"info\")\n --tls Use TLS; implied by --tlsverify\n --tlscacert string Trust certs signed only by this CA (default\n \"~/.docker/ca.pem\")\n --tlscert string Path to TLS certificate file (default\n \"~/.docker/cert.pem\")\n --tlskey string Path to TLS key file (default\n \"~/.docker/key.pem\")\n --tlsverify Use TLS and verify the remote\n -v, --version Print version information and quit\n\nRun 'docker COMMAND --help' for more information on a command.\n\nFor more help on how to use Docker, head to https://docs.docker.com/go/guides/\n```\n", "license": "Apache-2.0", "homepage": "https://www.docker.com", "source_url": "https://github.com/moby/moby", @@ -202,34 +202,54 @@ "url": "https://pilotprotocol.network", "contact": "apps@pilotprotocol.network", "agent_usage": "On a Linux host with root, agents call docker.engine_start to boot a local dockerd, then docker.pull/docker.run/docker.ps/docker.images/docker.logs and docker.exec (any docker command with a verbatim argv, incl. build/exec/networks/volumes). Set DOCKER_HOST to target an existing daemon instead. Output is text, or {stdout,stderr,exit} on a non-zero exit.", - "capabilities": "Delivers the official Docker 29.6.1 static Engine+CLI (dockerd, containerd, runc, containerd-shim-runc-v2, docker-proxy, docker-init, ctr, docker) as a sha-pinned per-arch Linux bundle with a dockerctl wrapper that manages the daemon lifecycle and fronts the CLI. Run real OCI containers on a Linux host/VM/privileged container. Apache-2.0; repackaged unmodified." + "capabilities": "Delivers the official Docker 29.8.1 static Engine+CLI (dockerd, containerd, runc, containerd-shim-runc-v2, docker-proxy, docker-init, ctr, docker) as a sha-pinned per-arch Linux bundle with a dockerctl wrapper that manages the daemon lifecycle and fronts the CLI. Run real OCI containers on a Linux host/VM/privileged container. Apache-2.0; repackaged unmodified." }, "artifacts": [ { "role": "binary", "os": "linux", "arch": "amd64", - "url": "https://pub-2328865fa11041b8a5efba00b940ec14.r2.dev/io.pilot.docker/29.6.1/linux-amd64/docker-29.6.1-linux-amd64.tar.gz", - "sha256": "af67328c2d617401f22c108b2ebe5f06e6f59978c50a3f2c07d5f0041216172d", + "url": "https://pub-2328865fa11041b8a5efba00b940ec14.r2.dev/io.pilot.docker/29.8.1/linux-amd64/docker-29.8.1-linux-amd64.tar.gz", + "sha256": "5f283010469955c586ea8b3f2c8a0762d7ded7c2d2611f244eff4e50a303e158", "unpack": "tar.gz", - "exec_path": "docker-29.6.1-linux-amd64/bin/dockerctl", + "exec_path": "docker-29.8.1-linux-amd64/bin/dockerctl", "order": 1 }, { "role": "binary", "os": "linux", "arch": "arm64", - "url": "https://pub-2328865fa11041b8a5efba00b940ec14.r2.dev/io.pilot.docker/29.6.1/linux-arm64/docker-29.6.1-linux-arm64.tar.gz", - "sha256": "c16db13a77f40fd9af7fe5520cb1bd9c78b5c2ab2d6e1749863683dce957f572", + "url": "https://pub-2328865fa11041b8a5efba00b940ec14.r2.dev/io.pilot.docker/29.8.1/linux-arm64/docker-29.8.1-linux-arm64.tar.gz", + "sha256": "e332d971c224eede2a5f421eb064999fa177c3393e4b29f77388509d663cb1b7", "unpack": "tar.gz", - "exec_path": "docker-29.6.1-linux-arm64/bin/dockerctl", + "exec_path": "docker-29.8.1-linux-arm64/bin/dockerctl", + "order": 1 + }, + { + "role": "binary", + "os": "darwin", + "arch": "arm64", + "url": "https://pub-2328865fa11041b8a5efba00b940ec14.r2.dev/io.pilot.docker/29.8.1/darwin-arm64/docker-29.8.1-darwin-arm64.tar.gz", + "sha256": "085781ff82e76cbbf91202d793a21671f9d0569f66f4fd499b4ba67ad63e6f35", + "unpack": "tar.gz", + "exec_path": "docker-29.8.1-darwin-arm64/bin/dockerctl", + "order": 1 + }, + { + "role": "binary", + "os": "darwin", + "arch": "amd64", + "url": "https://pub-2328865fa11041b8a5efba00b940ec14.r2.dev/io.pilot.docker/29.8.1/darwin-amd64/docker-29.8.1-darwin-amd64.tar.gz", + "sha256": "54966b446ae2d2fe6a4c9dab2c95bd8443a2f23ffef3a3bcc7f56bb2b1d48277", + "unpack": "tar.gz", + "exec_path": "docker-29.8.1-darwin-amd64/bin/dockerctl", "order": 1 } ], "product_demo": { "skill": "io.pilot.docker", "title": "Full usage demo", - "when_to_use": "When you need to run real OCI containers on a Linux host \u2014 pull images and run/build/exec containers via a local Docker Engine \u2014 without Docker Desktop.", + "when_to_use": "When you need to run real OCI containers from an agent \u2014 pull images and run/build/exec containers \u2014 on a Linux host (bundled engine) or a Mac with Docker Desktop, OrbStack or colima.", "metered": false, "quickstart": { "goal": "Boot a local Docker Engine", @@ -266,7 +286,7 @@ ], "gotchas": [ "LINUX-ONLY: there is no native macOS dockerd (Docker Desktop hides a Linux VM), so this app cannot start an engine on macOS.", - "docker.engine_start needs root \u2014 dockerd manages namespaces/cgroups; run on a Linux host/VM/privileged container.", + "docker.engine_start uses a running engine if there is one; starting the bundled dockerd needs root on Linux, and macOS has no local dockerd.", "Call docker.engine_start once before other methods, or set DOCKER_HOST to target an existing daemon and skip it.", "docker.run uses --rm and the image's default command; for flags, ports, detached, or build/exec use docker.exec.", "On a non-zero exit the reply is {stdout, stderr, exit}." @@ -287,7 +307,7 @@ "then": [ { "cmd": "pilotctl appstore call io.pilot.docker docker.engine_start '{}'", - "why": "boot the local engine once, then retry \u2014 needs root, and Linux only (there is no native macOS dockerd)", + "why": "finds a running engine or, on Linux as root, boots the bundled one; then retry", "kind": "gateway" } ] @@ -300,7 +320,7 @@ "then": [ { "cmd": "pilotctl appstore call io.pilot.docker docker.engine_start '{}'", - "why": "boot the local engine once, then retry \u2014 needs root, and Linux only (there is no native macOS dockerd)", + "why": "finds a running engine or, on Linux as root, boots the bundled one; then retry", "kind": "gateway" } ] diff --git a/submissions/io.pilot.kinetic/submission.json b/submissions/io.pilot.kinetic/submission.json new file mode 100644 index 0000000..bc64a34 --- /dev/null +++ b/submissions/io.pilot.kinetic/submission.json @@ -0,0 +1,871 @@ +{ + "product_demo": { + "skill": "io.pilot.kinetic", + "title": "Full usage demo", + "metered": false, + "when_to_use": "When you need to know what to charge and want it grounded in your own customers' answers: a price range, a specific price point, or which features carry the value.", + "quickstart": { + "goal": "Find out which study answers your question", + "command": "pilotctl appstore call io.pilot.kinetic kinetic.method_recommend '{\"decision\":\"price_range\"}'", + "expect": "{\"decision\":\"price_range\",\"recommended\":{\"study_type\":\"van_westendorp\",\"price_cents\":14900},\"why\":\"...\",\"alternatives\":[...]}", + "note": "Free, no token needed. decision is one of price_range, exact_price, feature_priorities, package_pricing — the methods answer different questions." + }, + "examples": [ + { + "title": "Save your API token", + "goal": "Every study, results and billing call needs it", + "command": "pilotctl appstore call io.pilot.kinetic kinetic.set_key '{\"api_key\":\"kp_live_.\"}'", + "expect": "{\"ok\":true,\"key_field\":\"KINETIC_API_TOKEN\",\"api_key\":\"saved\"}", + "note": "Create the token on kineticpricing.com under Integrations. No account yet? kinetic.signup_start emails the holder a confirmation link." + }, + { + "title": "Draft a study and preview it", + "goal": "Everything up to launch is free", + "command": "pilotctl appstore call io.pilot.kinetic kinetic.study_create '{\"Idempotency-Key\":\"\",\"name\":\"Pro tier\",\"product_desc\":\"SaaS analytics for small teams\",\"segment_desc\":\"Founders at 5-50 person startups\",\"decision_type\":\"first_time\",\"currency\":\"USD\"}'", + "expect": "{\"id\":\"3572c965-...\",\"methodology\":\"van_westendorp\",\"status\":\"draft\",\"price_cents\":14900}", + "note": "decision_type picks the methodology for you. Every mutating call needs an Idempotency-Key." + }, + { + "title": "Check the price before spending", + "command": "pilotctl appstore call io.pilot.kinetic kinetic.offer_list '{}'", + "expect": "{\"offers\":[{\"study_type\":\"van_westendorp\",\"price_cents\":14900}]}", + "note": "Launching costs $149-$199, paid by the account holder on Stripe." + }, + { + "title": "Read computed results, not guesses", + "command": "pilotctl appstore call io.pilot.kinetic kinetic.results_get '{\"id\":\"stu_abc123\"}'", + "expect": "{\"optimal_price_point\":42,\"acceptable_range\":{\"low\":34,\"high\":58},\"n\":31}", + "note": "Call study_quality_get first: speeders make a clean number worthless." + } + ], + "gotchas": [ + "Save your token first: kinetic.set_key {\"api_key\":\"kp_live_...\"}. Only method_list, method_recommend, offer_list and research work without one.", + "Everything except launching is free. The $149-$199 fee is paid by a human on Stripe.", + "Every mutating call needs an Idempotency-Key header; a retry with the same key is safe.", + "study_checkout_create returns a link and does NOT pay. Hand the URL to your human and stop.", + "You supply the respondents; Kinetic has no panel.", + "Check study_quality_get and progress before trusting a result." + ], + "next": [ + "io.pilot.kinetic kinetic.help '{}'" + ] + }, + "next_steps": { + "schema": 1, + "app": "io.pilot.kinetic", + "edges": [ + { + "from": "*", + "on": "ok", + "match": "\"needs_signup\"\\s*:\\s*true", + "why": "this host has no Kinetic token yet, so study, results and billing calls cannot run", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.kinetic kinetic.set_key '{\"api_key\":\"kp_live_.\"}'", + "why": "the account holder creates the token under Integrations on kineticpricing.com", + "kind": "gateway" + }, + { + "cmd": "pilotctl appstore call io.pilot.kinetic kinetic.signup_start '{\"email\":\"\",\"authority_basis\":\"authorized_agent\"}'", + "why": "no account yet: Kinetic emails the holder a confirmation link", + "kind": "recovery" + } + ] + }, + { + "from": "kinetic.method_recommend", + "on": "ok", + "why": "you now know which method answers your question, so create that study as a free draft", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.kinetic kinetic.study_create '{\"study_type\":\"van_westendorp\",\"name\":\"Pro tier pricing\"}'", + "why": "use the study_type the recommendation returned; drafting and editing are free", + "kind": "flow" + } + ] + }, + { + "from": "kinetic.study_create", + "on": "ok", + "why": "a draft costs nothing, so the next free step is reading the survey exactly as a respondent will see it", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.kinetic kinetic.survey_preview '{\"id\":\"\"}'", + "why": "preview stores no respondent evidence, and once the study is live the questions are fixed", + "kind": "flow" + } + ] + }, + { + "from": "kinetic.study_checkout_create", + "on": "ok", + "why": "this returned a payment link and did not complete the purchase, so nothing moves until a human pays", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.kinetic kinetic.study_get '{\"id\":\"\"}'", + "why": "give checkout_url to your human, wait for them to pay on Stripe, then poll here before launching", + "kind": "flow" + } + ] + }, + { + "from": "kinetic.results_get", + "on": "ok", + "why": "a clean-looking number can come from bad responses, so quality is worth checking before acting on it", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.kinetic kinetic.study_quality_get '{\"id\":\"\"}'", + "why": "speeders and straight-liners make results worthless; also confirm progress cleared the minimum sample", + "kind": "flow" + } + ] + }, + { + "from": "kinetic.study_launch", + "on": "err", + "code": 402, + "why": "the study has not been paid for, so it cannot go live yet", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.kinetic kinetic.study_checkout_create '{\"study_id\":\"\"}'", + "why": "returns a Stripe link for your human to pay; launch works once payment clears", + "kind": "recovery" + } + ] + }, + { + "from": "kinetic.signup_start", + "on": "ok", + "why": "nothing exists until the holder clicks the emailed link", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.kinetic kinetic.signup_status '{\"id\":\"\",\"attempt_token\":\"\"}'", + "why": "poll until state is verified, then complete with the holder's consent", + "kind": "flow" + } + ] + }, + { + "from": "kinetic.signup_complete", + "on": "ok", + "why": "the account exists but this host still has no API token", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.kinetic kinetic.set_key '{\"api_key\":\"kp_live_.\"}'", + "why": "the holder creates a token under Integrations; save it here", + "kind": "flow" + } + ] + } + ] + }, + "id": "io.pilot.kinetic", + "version": "1.0.1", + "namespace": "kinetic", + "description": "Run real pricing research with your own customers: pick the method that fits the decision, draft and preview the survey, collect responses, and read deterministic results with a narrative written from those numbers. Van Westendorp, Gabor-Granger, MaxDiff and choice-based conjoint.", + "email": "support@kineticpricing.com", + "backend": { + "base_url": "https://api.kineticpricing.com", + "auth": "byo", + "headers": [ + { + "name": "Authorization", + "value": "Bearer ${KINETIC_API_TOKEN}" + } + ] + }, + "methods": [ + { + "name": "kinetic.set_key", + "latency": "fast", + "signup": { + "step": "set_key", + "url": "https://kineticpricing.com", + "secret_key": "KINETIC_API_TOKEN" + }, + "description": "Save the Kinetic API access token this host uses (format kp_live_.). The account holder creates it on kineticpricing.com under Integrations, choosing its scopes. It is stored in $APP/secrets.json and sent on every call; calling again replaces it. Local, free. Call this before any study, results or billing method.", + "params": [ + { + "name": "api_key", + "type": "string", + "required": true, + "description": "The kp_live_... token from Integrations on kineticpricing.com." + } + ] + }, + { + "name": "kinetic.method_list", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/methods", + "public": true + }, + "description": "List the pricing-research methods available, with each one's server-owned launch price in cents, methodology version, and sample-size guidance. Van Westendorp finds an acceptable price RANGE, Gabor-Granger tests exact price POINTS, MaxDiff ranks feature value, choice-based conjoint models trade-offs. Free, and the right first call when you do not yet know which study to run.", + "params": [] + }, + { + "name": "kinetic.method_recommend", + "latency": "med", + "http": { + "verb": "POST", + "path": "/v1/methods/recommendation", + "public": true + }, + "description": "Say which kind of pricing decision you face and get back the method that answers it, with the reason and the alternatives. Free, no token needed. Use this instead of guessing between Van Westendorp and Gabor-Granger: they answer different questions, and the wrong one produces a confident, useless number.", + "params": [ + { + "name": "decision", + "type": "string", + "required": true, + "in": "body", + "description": "One of: price_range (what range can I charge), exact_price (which of these specific prices), feature_priorities (which features carry the value), package_pricing (how to package tiers)." + } + ] + }, + { + "name": "kinetic.study_create", + "latency": "med", + "http": { + "verb": "POST", + "path": "/v1/studies" + }, + "description": "START HERE once you know the decision. Creates a draft study. You do NOT pick the method: Kinetic derives the methodology from decision_type (first_time and new_tier map to Van Westendorp today) and returns it on the study along with price_cents. Free — a draft costs nothing and is not launched. Returns {id, methodology, status:\"draft\", price_cents}.", + "params": [ + { + "name": "Idempotency-Key", + "type": "string", + "required": true, + "description": "Any unique string per distinct request, e.g. a UUID. Required on every mutating call; a retry with the same key returns the original result instead of creating a second study.", + "in": "header" + }, + { + "name": "name", + "type": "string", + "required": true, + "description": "A name for the study.", + "in": "body" + }, + { + "name": "product_desc", + "type": "string", + "required": true, + "description": "What you sell, in plain language, e.g. 'A SaaS analytics tool for small teams'.", + "in": "body" + }, + { + "name": "segment_desc", + "type": "string", + "required": true, + "description": "Who you are pricing for, e.g. 'Founders at 5-50 person startups'.", + "in": "body" + }, + { + "name": "decision_type", + "type": "string", + "required": true, + "description": "One of underpriced | new_tier | first_time | other. This is what selects the methodology, so it is the most consequential field on the call.", + "in": "body" + }, + { + "name": "currency", + "type": "string", + "required": true, + "description": "ISO currency code, e.g. USD.", + "in": "body" + } + ] + }, + { + "name": "kinetic.study_prefill", + "latency": "slow", + "http": { + "verb": "POST", + "path": "/v1/studies/prefill" + }, + "description": "Generate a draft study filled in from a description of your product and audience, so you edit a sensible starting point rather than an empty form. Free.", + "params": [ + { + "name": "Idempotency-Key", + "type": "string", + "required": true, + "description": "Any unique string per distinct request, e.g. a UUID. Required on mutating calls; a retry with the same key returns the original result rather than repeating the operation.", + "in": "header" + }, + { + "name": "description", + "type": "string", + "required": false, + "description": "What you sell and to whom, in plain language.", + "in": "body" + } + ] + }, + { + "name": "kinetic.study_get", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/studies/{study_id}" + }, + "description": "Read one study: its configuration, state, and position in the lifecycle. Free.", + "params": [ + { + "name": "study_id", + "type": "string", + "required": true, + "description": "The study id.", + "in": "path" + } + ] + }, + { + "name": "kinetic.study_list", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/studies" + }, + "description": "List your studies with their states. Free.", + "params": [] + }, + { + "name": "kinetic.study_update", + "latency": "med", + "http": { + "verb": "PATCH", + "path": "/v1/studies/{study_id}" + }, + "description": "Edit a draft study: name, prices, features, audience and question configuration. Only the fields you send change. Free, and only valid before launch.", + "params": [ + { + "name": "Idempotency-Key", + "type": "string", + "required": true, + "description": "Any unique string per distinct request, e.g. a UUID. Required on mutating calls; a retry with the same key returns the original result rather than repeating the operation.", + "in": "header" + }, + { + "name": "study_id", + "type": "string", + "required": true, + "description": "The study id.", + "in": "path" + } + ] + }, + { + "name": "kinetic.study_duplicate", + "latency": "med", + "http": { + "verb": "POST", + "path": "/v1/studies/{study_id}/duplicate" + }, + "description": "Copy an existing study into a new draft, to run the same design against a different audience or set of prices. Free.", + "params": [ + { + "name": "Idempotency-Key", + "type": "string", + "required": true, + "description": "Any unique string per distinct request, e.g. a UUID. Required on mutating calls; a retry with the same key returns the original result rather than repeating the operation.", + "in": "header" + }, + { + "name": "study_id", + "type": "string", + "required": true, + "description": "The study to copy.", + "in": "path" + } + ] + }, + { + "name": "kinetic.survey_regenerate", + "latency": "med", + "http": { + "verb": "POST", + "path": "/v1/studies/{study_id}/survey/regenerate" + }, + "description": "Regenerate the respondent-facing survey from the study's current configuration after editing it. Free.", + "params": [ + { + "name": "Idempotency-Key", + "type": "string", + "required": true, + "description": "Any unique string per distinct request, e.g. a UUID. Required on mutating calls; a retry with the same key returns the original result rather than repeating the operation.", + "in": "header" + }, + { + "name": "study_id", + "type": "string", + "required": true, + "description": "The study id.", + "in": "path" + } + ] + }, + { + "name": "kinetic.survey_preview", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/studies/{study_id}/preview" + }, + "description": "Read the survey exactly as a respondent will see it, WITHOUT storing any respondent evidence. Free. Always preview before launching: once a study is live the questions are fixed, and a bad question wastes the whole sample.", + "params": [ + { + "name": "study_id", + "type": "string", + "required": true, + "description": "The study id.", + "in": "path" + } + ] + }, + { + "name": "kinetic.study_launch", + "latency": "slow", + "http": { + "verb": "POST", + "path": "/v1/studies/{study_id}/launch" + }, + "description": "Take a draft live so it can collect responses. COSTS MONEY: the launch price comes from kinetic.method_list (currently $149-$199 by method) and must be paid first via kinetic.study_checkout_create. Confirm with your human before calling. Once live the questions are fixed.", + "params": [ + { + "name": "Idempotency-Key", + "type": "string", + "required": true, + "description": "Any unique string per distinct request, e.g. a UUID. Required on mutating calls; a retry with the same key returns the original result rather than repeating the operation.", + "in": "header" + }, + { + "name": "study_id", + "type": "string", + "required": true, + "description": "The study id.", + "in": "path" + } + ] + }, + { + "name": "kinetic.respondent_link_get", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/studies/{study_id}/respondent-link" + }, + "description": "Get the link to send to your customers or prospects. THEY are the respondents: Kinetic supplies no panel, so the quality of the answer depends on who you send this to. Free.", + "params": [ + { + "name": "study_id", + "type": "string", + "required": true, + "description": "The study id.", + "in": "path" + } + ] + }, + { + "name": "kinetic.study_progress_get", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/studies/{study_id}/progress" + }, + "description": "How many responses have arrived against the recommended sample size. Free. Poll after launch; results are only meaningful once you clear the method's recommended minimum.", + "params": [ + { + "name": "study_id", + "type": "string", + "required": true, + "description": "The study id.", + "in": "path" + } + ] + }, + { + "name": "kinetic.study_quality_get", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/studies/{study_id}/quality" + }, + "description": "Response-quality signals: speeders, straight-liners, and other patterns meaning a response should not be trusted. Free. Check this before believing the results.", + "params": [ + { + "name": "study_id", + "type": "string", + "required": true, + "description": "The study id.", + "in": "path" + } + ] + }, + { + "name": "kinetic.study_close", + "latency": "med", + "http": { + "verb": "POST", + "path": "/v1/studies/{study_id}/close" + }, + "description": "Stop collecting responses and finalize the study so results can be computed. Free.", + "params": [ + { + "name": "Idempotency-Key", + "type": "string", + "required": true, + "description": "Any unique string per distinct request, e.g. a UUID. Required on mutating calls; a retry with the same key returns the original result rather than repeating the operation.", + "in": "header" + }, + { + "name": "study_id", + "type": "string", + "required": true, + "description": "The study id.", + "in": "path" + } + ] + }, + { + "name": "kinetic.results_get", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/studies/{study_id}/results" + }, + "description": "The deterministic results: numbers Kinetic's analysis engine computed from the responses, not a model's opinion of them. Free. This is the output you act on.", + "params": [ + { + "name": "study_id", + "type": "string", + "required": true, + "description": "The study id.", + "in": "path" + } + ] + }, + { + "name": "kinetic.evidence_get", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/studies/{study_id}/evidence" + }, + "description": "The structured respondent evidence behind the results, so a number can be traced to the answers that produced it. Free.", + "params": [ + { + "name": "study_id", + "type": "string", + "required": true, + "description": "The study id.", + "in": "path" + } + ] + }, + { + "name": "kinetic.narrative_generate", + "latency": "med", + "http": { + "verb": "POST", + "path": "/v1/studies/{study_id}/narrative" + }, + "description": "Generate a written explanation of the results. The narrative is written FROM the deterministic results, so it explains the computed numbers rather than inventing them. Free.", + "params": [ + { + "name": "Idempotency-Key", + "type": "string", + "required": true, + "description": "Any unique string per distinct request, e.g. a UUID. Required on mutating calls; a retry with the same key returns the original result rather than repeating the operation.", + "in": "header" + }, + { + "name": "study_id", + "type": "string", + "required": true, + "description": "The study id.", + "in": "path" + } + ] + }, + { + "name": "kinetic.narrative_get", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/studies/{study_id}/narrative" + }, + "description": "Read a previously generated narrative. Free.", + "params": [ + { + "name": "study_id", + "type": "string", + "required": true, + "description": "The study id.", + "in": "path" + } + ] + }, + { + "name": "kinetic.results_export_csv", + "latency": "slow", + "http": { + "verb": "POST", + "path": "/v1/studies/{study_id}/exports/csv" + }, + "description": "Export results and responses as CSV for analysis elsewhere. Free.", + "params": [ + { + "name": "Idempotency-Key", + "type": "string", + "required": true, + "description": "Any unique string per distinct request, e.g. a UUID. Required on mutating calls; a retry with the same key returns the original result rather than repeating the operation.", + "in": "header" + }, + { + "name": "study_id", + "type": "string", + "required": true, + "description": "The study id.", + "in": "path" + } + ] + }, + { + "name": "kinetic.offer_list", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/billing/offers", + "public": true + }, + "description": "List what is purchasable and what it costs: one-time study prices and Kinetic Pro plans. Free, and the honest place to check a price before committing to a spend.", + "params": [] + }, + { + "name": "kinetic.study_checkout_create", + "latency": "slow", + "http": { + "verb": "POST", + "path": "/v1/billing/checkouts/study" + }, + "description": "Start payment for a draft study. Returns {checkout_url, study_id, amount_cents} and does NOT complete the purchase. A human must open checkout_url and pay on Stripe's hosted page; hand them the link and stop. Once paid, kinetic.study_launch will work. Confirm the amount with your human first.", + "params": [ + { + "name": "Idempotency-Key", + "type": "string", + "required": true, + "description": "Any unique string per distinct request, e.g. a UUID. Required on mutating calls; a retry with the same key returns the original result rather than repeating the operation.", + "in": "header" + }, + { + "name": "study_id", + "type": "string", + "required": true, + "description": "The draft study to pay for.", + "in": "body" + } + ] + }, + { + "name": "kinetic.teardown_create", + "latency": "slow", + "http": { + "verb": "POST", + "path": "/v1/teardowns" + }, + "description": "Run a teardown of a public pricing page and get a structured read of how it is constructed. Free.", + "params": [ + { + "name": "Idempotency-Key", + "type": "string", + "required": true, + "description": "Any unique string per distinct request, e.g. a UUID. Required on mutating calls; a retry with the same key returns the original result rather than repeating the operation.", + "in": "header" + }, + { + "name": "url", + "type": "string", + "required": false, + "description": "The pricing page URL to tear down.", + "in": "body" + } + ] + }, + { + "name": "kinetic.teardown_get", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/teardowns/{teardown_id}" + }, + "description": "Read a completed teardown. Free.", + "params": [ + { + "name": "teardown_id", + "type": "string", + "required": true, + "description": "The teardown id.", + "in": "path" + } + ] + }, + { + "name": "kinetic.research_list", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/research", + "public": true + }, + "description": "Browse Kinetic's published pricing research. Free.", + "params": [] + }, + { + "name": "kinetic.research_get", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/research/{slug}", + "public": true + }, + "description": "Read one published research piece. Free.", + "params": [ + { + "name": "slug", + "type": "string", + "required": true, + "description": "The research slug from kinetic.research_list.", + "in": "path" + } + ] + }, + { + "name": "kinetic.task_get", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/tasks/{task_id}" + }, + "description": "Poll a long-running task (prefill, teardown, export) for completion. Free.", + "params": [ + { + "name": "task_id", + "type": "string", + "required": true, + "description": "The task id returned by the operation that started it.", + "in": "path" + } + ] + }, + { + "name": "kinetic.signup_start", + "latency": "med", + "http": { + "verb": "POST", + "path": "/v1/signup-attempts", + "public": true + }, + "description": "No Kinetic account yet? Start agent-assisted signup: Kinetic emails the account holder a confirmation link and returns an attempt id plus an attempt_token shown ONCE — keep both. The account only exists after the holder clicks the link and you call kinetic.signup_complete with their consent. Free.", + "params": [ + { + "name": "email", + "type": "string", + "required": true, + "description": "The account holder's email; the confirmation link goes there." + }, + { + "name": "authority_basis", + "type": "string", + "required": true, + "description": "\"self\" if the holder is signing up themselves, \"authorized_agent\" if you act for them." + } + ] + }, + { + "name": "kinetic.signup_status", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/signup-attempts/{id}", + "public": true + }, + "description": "Check a signup attempt: pending_verification until the holder clicks the emailed link, then verified, then completed (or expired). Free.", + "params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "The attempt id from kinetic.signup_start.", + "in": "path" + }, + { + "name": "attempt_token", + "type": "string", + "required": true, + "description": "The attempt_token from kinetic.signup_start." + } + ] + }, + { + "name": "kinetic.signup_complete", + "latency": "med", + "http": { + "verb": "POST", + "path": "/v1/signup-attempts/{id}/complete", + "public": true + }, + "description": "Create the account once signup_status is verified. Pass terms_accepted=true ONLY with the account holder's actual consent to Kinetic's terms. The holder then creates an API token under Integrations and you save it with kinetic.set_key. Free.", + "params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "The attempt id.", + "in": "path" + }, + { + "name": "attempt_token", + "type": "string", + "required": true, + "description": "The attempt_token from kinetic.signup_start." + }, + { + "name": "terms_accepted", + "type": "boolean", + "required": true, + "description": "Must be true, and only with the holder's consent." + } + ] + } + ], + "listing": { + "display_name": "Kinetic Pricing", + "tagline": "Decide what to charge, using answers from your own customers rather than a guess.", + "app_description": "**Bring your own Kinetic token.** Create an API access token under Integrations on kineticpricing.com and save it once with `kinetic.set_key`. No account yet? `kinetic.signup_start` emails the account holder a confirmation link; after they confirm, `kinetic.signup_complete` creates the account. `method_list`, `method_recommend`, `offer_list` and the research library work without a token.\n\nKinetic Pricing runs real pricing research end to end: choose the method that fits the decision, draft and preview the survey, send a link to your own customers, and read results computed by an analysis engine rather than asserted by a model.\n\n**Pick the right method or the answer is worthless.** `method_recommend` takes the decision in plain language and returns the method that answers it. Van Westendorp finds an acceptable price *range*; Gabor-Granger tests specific price *points* and produces a demand curve; MaxDiff ranks which features carry value; choice-based conjoint models trade-offs. Asking the wrong one produces a confident, useless number.\n\n**Everything up to launch is free.** Drafting, editing, regenerating the survey and previewing it cost nothing, and preview stores no respondent evidence. Launching costs $149-$199 depending on method, and that fee is paid by the account holder on Stripe's hosted page. `study_checkout_create` returns the link and explicitly does not complete the purchase.\n\n**You supply the respondents.** Kinetic has no panel. `respondent_link_get` gives a link to send to your own customers or prospects, so the quality of the answer depends on who you send it to.\n\n**The numbers are computed, not narrated.** `results_get` returns what the analysis engine calculated, `evidence_get` traces a number back to the answers behind it, and `narrative_generate` writes an explanation *from* those results. Check `study_quality_get` before believing any of it, since speeders and straight-liners produce clean-looking nonsense, and check `study_progress_get` against the method's recommended minimum sample.\n\nAlso included: pricing-page teardowns, Kinetic's published research, and CSV export.", + "license": "Proprietary", + "homepage": "https://kineticpricing.com", + "source_url": "https://kineticpricing.com", + "categories": [ + "business", + "analytics", + "research" + ], + "keywords": [ + "pricing", + "willingness to pay", + "van westendorp", + "gabor granger", + "maxdiff", + "conjoint", + "market research", + "saas pricing" + ] + }, + "vendor": { + "name": "Kinetic Pricing", + "url": "https://kineticpricing.com", + "contact": "support@kineticpricing.com", + "agent_usage": "An agent starts with kinetic.method_recommend, describing the pricing decision in plain language, because Van Westendorp and Gabor-Granger answer different questions. It creates a free draft with kinetic.study_create, or kinetic.study_prefill from a product description, edits it with kinetic.study_update, and reads it exactly as a respondent will with kinetic.survey_preview, which stores no evidence. Launching costs money: kinetic.offer_list gives the price, kinetic.study_checkout_create returns a Stripe link a human must open and pay, and only then does kinetic.study_launch succeed. The agent hands the link from kinetic.respondent_link_get to its human to distribute, since Kinetic supplies no panel. It polls kinetic.study_progress_get until the recommended minimum sample is cleared, checks kinetic.study_quality_get for speeders and straight-liners, then reads kinetic.results_get for the deterministic numbers, kinetic.evidence_get to trace them, and kinetic.narrative_generate for an explanation written from those results.", + "capabilities": "method_list and method_recommend: choose the study that actually answers the pricing decision.\nstudy_create, study_prefill, study_update, study_duplicate: build and edit a draft, free until launch.\nsurvey_preview and survey_regenerate: read the survey as a respondent will, storing no evidence.\noffer_list and study_checkout_create: see the price and get a Stripe link a human pays; the call does not complete the purchase.\nstudy_launch and study_close: take a paid study live and finalize it.\nrespondent_link_get: the link you send to your own customers, since there is no panel.\nstudy_progress_get and study_quality_get: sample progress, and the speeder and straight-liner signals that decide whether results mean anything.\nresults_get, evidence_get, narrative_generate, results_export_csv: computed results, the answers behind them, a written explanation, and CSV.\nteardown_create and research_list: pricing-page teardowns and Kinetic's published research." + } +} \ No newline at end of file diff --git a/submissions/io.pilot.rentahuman/submission.json b/submissions/io.pilot.rentahuman/submission.json new file mode 100644 index 0000000..35fd23c --- /dev/null +++ b/submissions/io.pilot.rentahuman/submission.json @@ -0,0 +1,733 @@ +{ + "id": "io.pilot.rentahuman", + "version": "0.3.0", + "namespace": "rentahuman", + "description": "Hire real people for physical-world tasks and recurring human QA with your own Rent A Human API key: search humans, book services, hire with escrow, and run QA of a URL.", + "email": "support@rentahuman.ai", + "backend": { + "type": "http", + "base_url": "https://rentahuman.ai/api", + "auth": "byo", + "headers": [ + { + "name": "X-API-Key", + "value": "${RENTAHUMAN_API_KEY}" + } + ] + }, + "methods": [ + { + "name": "rentahuman.set_key", + "latency": "fast", + "signup": { + "step": "set_key", + "url": "https://rentahuman.ai/account/api-keys", + "secret_key": "RENTAHUMAN_API_KEY" + }, + "description": "Save the Rent A Human API key this host uses (format rah_ followed by 32 hex characters). Create one at https://rentahuman.ai/account/api-keys. It is stored in $APP/secrets.json and sent as X-API-Key on every call; calling again replaces it. Local, free. Needed for booking, hiring, escrow and QA; search and browse work without it.", + "params": [ + { + "name": "api_key", + "type": "string", + "required": true, + "description": "Your rah_... key from rentahuman.ai/account/api-keys." + } + ] + }, + { + "name": "rentahuman.search_humans", + "description": "Find available humans by skill, name, hourly rate, city, or country.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/humans", + "public": true + }, + "params": [ + { + "name": "skill", + "type": "string", + "required": false, + "description": "Skill filter.", + "in": "query" + }, + { + "name": "name", + "type": "string", + "required": false, + "description": "Case-insensitive name filter.", + "in": "query" + }, + { + "name": "minRate", + "type": "number", + "required": false, + "description": "Minimum hourly rate in USD.", + "in": "query" + }, + { + "name": "maxRate", + "type": "number", + "required": false, + "description": "Maximum hourly rate in USD.", + "in": "query" + }, + { + "name": "city", + "type": "string", + "required": false, + "description": "City filter.", + "in": "query" + }, + { + "name": "country", + "type": "string", + "required": false, + "description": "Country name or ISO code.", + "in": "query" + }, + { + "name": "limit", + "type": "int", + "required": false, + "description": "Maximum results; default 50, max 200.", + "in": "query" + }, + { + "name": "offset", + "type": "int", + "required": false, + "description": "Pagination offset.", + "in": "query" + } + ] + }, + { + "name": "rentahuman.get_human", + "description": "Get a public human profile with skills, availability, rate, services, and identity signals.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/humans/{humanId}", + "public": true + }, + "params": [ + { + "name": "humanId", + "type": "string", + "required": true, + "description": "Human profile ID.", + "in": "path" + } + ] + }, + { + "name": "rentahuman.get_reviews", + "description": "Read a human's reviews before hiring or booking.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/reviews", + "public": true + }, + "params": [ + { + "name": "humanId", + "type": "string", + "required": true, + "description": "Human profile ID.", + "in": "query" + }, + { + "name": "limit", + "type": "int", + "required": false, + "description": "Maximum results; default 50, max 100.", + "in": "query" + }, + { + "name": "cursor", + "type": "string", + "required": false, + "description": "Pagination cursor.", + "in": "query" + } + ] + }, + { + "name": "rentahuman.browse_services", + "description": "Browse bookable human services with provider details, pricing, and duration.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/services/browse", + "public": true + }, + "params": [ + { + "name": "search", + "type": "string", + "required": false, + "description": "Search services.", + "in": "query" + }, + { + "name": "category", + "type": "string", + "required": false, + "description": "Service category.", + "in": "query" + }, + { + "name": "sort", + "type": "string", + "required": false, + "description": "Sort order.", + "in": "query" + }, + { + "name": "limit", + "type": "int", + "required": false, + "description": "Results per page.", + "in": "query" + }, + { + "name": "page", + "type": "int", + "required": false, + "description": "Page number.", + "in": "query" + } + ] + }, + { + "name": "rentahuman.get_service_availability", + "description": "Get booked slots for a human on a date before choosing a service time.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/services/bookings", + "public": true + }, + "params": [ + { + "name": "humanId", + "type": "string", + "required": true, + "description": "Human profile ID.", + "in": "query" + }, + { + "name": "date", + "type": "string", + "required": true, + "description": "Date in YYYY-MM-DD format.", + "in": "query" + } + ] + }, + { + "name": "rentahuman.list_bounties", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/bounties", + "public": true + }, + "description": "List open bounties that agents have posted for humans, newest first. Free, no key needed.", + "params": [ + { + "name": "limit", + "type": "int", + "required": false, + "description": "Page size.", + "in": "query" + } + ] + }, + { + "name": "rentahuman.book_service", + "description": "Reserve a listed service and return a Stripe Checkout URL for the operator to fund escrow.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/services/book" + }, + "params": [ + { + "name": "humanId", + "type": "string", + "required": true, + "description": "Human profile ID.", + "in": "body" + }, + { + "name": "serviceId", + "type": "string", + "required": true, + "description": "Service ID from browse_services.", + "in": "body" + }, + { + "name": "date", + "type": "string", + "required": true, + "description": "Date in YYYY-MM-DD, within the next 30 days.", + "in": "body" + }, + { + "name": "startTime", + "type": "string", + "required": true, + "description": "Start time in HH:mm.", + "in": "body" + } + ] + }, + { + "name": "rentahuman.rent_human", + "description": "Hire a specific human in one step; creates the task and escrow and returns a Stripe Checkout URL for standard accounts.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/escrow/agent-checkout" + }, + "params": [ + { + "name": "humanId", + "type": "string", + "required": true, + "description": "Human profile ID from search_humans.", + "in": "body" + }, + { + "name": "taskTitle", + "type": "string", + "required": true, + "description": "Task title, 5-200 characters.", + "in": "body" + }, + { + "name": "taskDescription", + "type": "string", + "required": true, + "description": "Detailed task description, at least 10 characters. Do not include private addresses or phone numbers.", + "in": "body" + }, + { + "name": "price", + "type": "number", + "required": true, + "description": "Agreed fixed price in USD, 5-10000.", + "in": "body" + }, + { + "name": "estimatedHours", + "type": "number", + "required": false, + "description": "Estimated duration in hours; minimum 0.083.", + "in": "body" + } + ] + }, + { + "name": "rentahuman.get_my_rentals", + "description": "List active and past rentals with status and the next action needed.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/escrow/agent-rentals" + }, + "params": [ + { + "name": "status", + "type": "string", + "required": false, + "description": "Optional escrow status filter.", + "in": "query" + } + ] + }, + { + "name": "rentahuman.confirm_delivery", + "description": "Confirm that delivered work is satisfactory before payment release. Only call after operator approval.", + "latency": "fast", + "http": { + "verb": "POST", + "path": "/escrow/{escrowId}/complete" + }, + "params": [ + { + "name": "escrowId", + "type": "string", + "required": true, + "description": "Escrow ID from get_my_rentals.", + "in": "path" + } + ] + }, + { + "name": "rentahuman.release_payment", + "description": "Irreversibly release confirmed escrow funds to the worker. Require explicit operator approval immediately before calling.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/escrow/{escrowId}/release" + }, + "params": [ + { + "name": "escrowId", + "type": "string", + "required": true, + "description": "Escrow ID in completed or warranty_hold status.", + "in": "path" + }, + { + "name": "applicationId", + "type": "string", + "required": false, + "description": "Optional application binding check to prevent paying the wrong worker.", + "in": "body" + } + ] + }, + { + "name": "rentahuman.cancel_escrow", + "description": "Cancel a funding or funded escrow and refund held funds. Require operator approval before calling.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/escrow/{escrowId}/cancel" + }, + "params": [ + { + "name": "escrowId", + "type": "string", + "required": true, + "description": "Escrow ID to cancel.", + "in": "path" + } + ] + }, + { + "name": "rentahuman.open_dispute", + "description": "Freeze an eligible escrow for Rent A Human admin review when work or terms are disputed.", + "latency": "fast", + "http": { + "verb": "POST", + "path": "/escrow/{escrowId}/dispute" + }, + "params": [ + { + "name": "escrowId", + "type": "string", + "required": true, + "description": "Escrow ID to dispute.", + "in": "path" + }, + { + "name": "category", + "type": "string", + "required": true, + "description": "One of not_delivered, poor_quality, not_as_described, communication_issue, non_payment, fraud, or other.", + "in": "body" + }, + { + "name": "description", + "type": "string", + "required": true, + "description": "Detailed reason, at least 20 characters.", + "in": "body" + } + ] + }, + { + "name": "rentahuman.create_qa_run_template", + "description": "Schedule one-time or recurring human testing of a target URL and return the active QA template. Runs collect photo, video, or document evidence and produce agent-reviewed reports. Requires wallet funds.", + "latency": "med", + "http": { + "verb": "POST", + "path": "/v1/qa/templates" + }, + "params": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "Template name, 3–120 characters.", + "in": "body" + }, + { + "name": "targetUrl", + "type": "string", + "required": true, + "description": "HTTP(S) product URL or journey to test.", + "in": "body" + }, + { + "name": "instructions", + "type": "string", + "required": true, + "description": "Testing instructions, 20–5,000 characters.", + "in": "body" + }, + { + "name": "testerStartMessage", + "type": "string", + "required": false, + "description": "Optional private test-only setup details sent after tester acceptance.", + "in": "body" + }, + { + "name": "cadence", + "type": "string", + "required": true, + "description": "One of once, daily, every_2_days, or weekly.", + "in": "body" + }, + { + "name": "budgetPerRunCents", + "type": "int", + "required": true, + "description": "Per-run budget ceiling in USD cents.", + "in": "body" + }, + { + "name": "payPerTesterCents", + "type": "int", + "required": true, + "description": "Pay per tester in USD cents.", + "in": "body" + }, + { + "name": "testerCount", + "type": "int", + "required": true, + "description": "Number of testers, 1–20.", + "in": "body" + }, + { + "name": "submissionMode", + "type": "string", + "required": true, + "description": "Required evidence: photo, video, or document.", + "in": "body" + }, + { + "name": "requiredCredentials", + "type": "object", + "required": false, + "description": "Optional JSON array of tester qualification strings.", + "in": "body" + }, + { + "name": "allowedCountries", + "type": "object", + "required": false, + "description": "Optional JSON array of ISO-2 country codes.", + "in": "body" + }, + { + "name": "periodCapCents", + "type": "int", + "required": true, + "description": "Monthly reservation cap; equals budgetPerRunCents when cadence is once.", + "in": "body" + } + ] + }, + { + "name": "rentahuman.list_qa_runs", + "description": "List up to 100 owned autonomous QA runs, optionally filtered by template ID, with status, budget totals, reviewed progress, and report availability.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/qa/runs" + }, + "params": [ + { + "name": "templateId", + "type": "string", + "required": false, + "description": "Optional QA template ID filter.", + "in": "query" + } + ] + }, + { + "name": "rentahuman.get_qa_run", + "description": "Get an owned autonomous QA run with status, budget totals, agent-reviewed report, finding diff, and escalation summaries.", + "latency": "fast", + "http": { + "verb": "GET", + "path": "/v1/qa/runs/{runId}" + }, + "params": [ + { + "name": "runId", + "type": "string", + "required": true, + "description": "QA run ID returned by list_qa_runs.", + "in": "path" + } + ] + } + ], + "vendor": { + "name": "Rent A Human", + "url": "https://rentahuman.ai", + "contact": "alex@rentahuman.ai", + "agent_usage": "Create an API key at https://rentahuman.ai/account/api-keys and supply it as RENTAHUMAN_API_KEY. For human QA, call create_qa_run_template with the target URL, instructions, cadence, tester count, evidence mode, and budget. Runs are wallet-funded; deposit if a low-balance warning is returned. Track them with list_qa_runs and retrieve the agent-reviewed report, finding diff, and escalations with get_qa_run. For individual work, use search_humans, get_human, and get_reviews; or browse_services, get_service_availability, and book_service. Stripe Checkout must be completed by the operator. Never confirm delivery, release payment, or cancel escrow without explicit operator approval immediately before the call. Use open_dispute for contested work.", + "capabilities": "- Schedule one-time or recurring human QA of a target URL\n- Collect photo, video, or document evidence from 1–20 testers\n- Retrieve agent-reviewed QA reports, finding diffs, and escalation summaries\n- Search available humans by skill, rate, name, city, or country\n- Inspect human profiles, identity signals, and reviews\n- Browse bookable services and check availability\n- Book a listed service with escrow-backed Stripe Checkout\n- Hire a specific human for an ad-hoc real-world task\n- Track active and past rentals with next-action guidance\n- Confirm satisfactory delivery\n- Release payment to a worker\n- Cancel an eligible escrow and refund held funds\n- Open a dispute for admin review" + }, + "listing": { + "display_name": "Rent A Human", + "tagline": "Dispatch humans for real-world tasks and recurring QA.", + "app_description": "**Bring your own Rent A Human key.** Create one at https://rentahuman.ai/account/api-keys and save it once with `rentahuman.set_key`. Searching humans, browsing services, reading reviews and listing bounties work without a key.\n\nRent A Human gives autonomous agents a typed path from digital intent to physical-world execution. Schedule one-time or recurring human QA of any URL, collect photo, video, or document evidence from 1–20 testers, and retrieve agent-reviewed reports with finding diffs. Agents can also search human profiles, inspect reviews, book services or hire a specific person, track escrow-backed work, and confirm, release, cancel, or dispute payment. Installers supply a Rent A Human API key. Wallet deposits and Stripe Checkout remain operator-controlled.", + "license": "MIT", + "homepage": "https://rentahuman.ai", + "source_url": "", + "categories": [ + "automation", + "marketplace", + "qa", + "testing", + "hiring", + "payments" + ], + "keywords": [ + "human qa", + "website testing", + "usability testing", + "humans", + "real-world tasks", + "marketplace", + "hiring", + "escrow", + "services", + "human-in-the-loop" + ] + }, + "product_demo": { + "skill": "io.pilot.rentahuman", + "title": "Full usage demo", + "metered": false, + "when_to_use": "When a task needs a real person — a local errand, an on-site check, or human QA of a website — and your agent should find, hire and pay them.", + "quickstart": { + "goal": "Find people who can do the task", + "command": "pilotctl appstore call io.pilot.rentahuman rentahuman.search_humans '{\"skill\":\"Photography\",\"city\":\"San Francisco\",\"limit\":5}'", + "expect": "{\"success\":true,\"humans\":[{\"id\":\"EbvgiotaiUFxmcPjnWtI\",\"name\":\"...\",\"headline\":\"...\"}]}", + "note": "Free, no key needed. Keep the id: every hire and booking takes a humanId." + }, + "examples": [ + { + "title": "Save your API key", + "goal": "Hiring, booking, escrow and QA need it", + "command": "pilotctl appstore call io.pilot.rentahuman rentahuman.set_key '{\"api_key\":\"rah_<32 hex>\"}'", + "expect": "{\"ok\":true,\"key_field\":\"RENTAHUMAN_API_KEY\",\"api_key\":\"saved\"}", + "note": "Create the key at rentahuman.ai/account/api-keys." + }, + { + "title": "Hire a specific person", + "goal": "One call creates the task and its escrow", + "command": "pilotctl appstore call io.pilot.rentahuman rentahuman.rent_human '{\"humanId\":\"EbvgiotaiUFxmcPjnWtI\",\"taskTitle\":\"Photograph a storefront\",\"taskDescription\":\"Take 10 daylight photos of the storefront at the address I send in chat.\",\"price\":60}'", + "expect": "{\"success\":true,\"escrowId\":\"...\",\"checkoutUrl\":\"https://checkout.stripe.com/...\"}", + "note": "Returns a Stripe Checkout URL: a human pays it to fund the escrow. Nothing is charged from the agent." + }, + { + "title": "Pay only after delivery", + "command": "pilotctl appstore call io.pilot.rentahuman rentahuman.confirm_delivery '{\"escrowId\":\"\"}'", + "expect": "{\"success\":true}", + "note": "Review the evidence first, then release_payment. cancel_escrow refunds; open_dispute if the work is wrong." + }, + { + "title": "Recurring human QA of a URL", + "command": "pilotctl appstore call io.pilot.rentahuman rentahuman.create_qa_run_template '{\"name\":\"Checkout smoke test\",\"targetUrl\":\"https://example.com/checkout\",\"instructions\":\"Buy the cheapest item with a test card and record each step.\",\"cadence\":\"weekly\",\"budgetPerRunCents\":3000,\"payPerTesterCents\":1000,\"testerCount\":2}'", + "expect": "{\"success\":true,\"template\":{\"id\":\"...\",\"status\":\"active\"}}", + "note": "Needs wallet funds on your Rent A Human account. Read results with list_qa_runs and get_qa_run." + } + ], + "gotchas": [ + "Search, browse, reviews and bounties are free and keyless; everything that books, hires or pays needs rentahuman.set_key first.", + "rent_human and book_service return a Stripe Checkout URL. A human pays it; the agent never holds card details.", + "Release payment only after reviewing the evidence: release_payment is final, cancel_escrow refunds.", + "Never put private addresses or phone numbers in task text; send them in the work conversation once someone accepts." + ], + "next": [ + "io.pilot.rentahuman rentahuman.help '{}'" + ] + }, + "next_steps": { + "schema": 1, + "app": "io.pilot.rentahuman", + "edges": [ + { + "from": "*", + "on": "ok", + "match": "\"needs_signup\"\\s*:\\s*true", + "why": "this host has no Rent A Human key yet, so hiring, booking, escrow and QA cannot run", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.rentahuman rentahuman.set_key '{\"api_key\":\"rah_<32 hex>\"}'", + "why": "create the key at rentahuman.ai/account/api-keys, then save it here", + "kind": "gateway" + } + ] + }, + { + "from": "rentahuman.search_humans", + "on": "ok", + "why": "you have candidate humanIds; look closer before hiring anyone", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.rentahuman rentahuman.get_human '{\"humanId\":\"\"}'", + "why": "check rate, location and skills", + "kind": "flow" + }, + { + "cmd": "pilotctl appstore call io.pilot.rentahuman rentahuman.get_reviews '{\"humanId\":\"\"}'", + "why": "read what earlier clients said", + "kind": "flow" + } + ] + }, + { + "from": "rentahuman.rent_human", + "on": "ok", + "why": "the escrow exists but is unfunded until a human pays the checkout link", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.rentahuman rentahuman.get_my_rentals '{}'", + "why": "hand checkoutUrl to your human, then watch the rental's status", + "kind": "flow" + } + ] + }, + { + "from": "rentahuman.confirm_delivery", + "on": "ok", + "why": "delivery is confirmed; the worker is paid only when you release", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.rentahuman rentahuman.release_payment '{\"escrowId\":\"\"}'", + "why": "release once the evidence checks out; it is final", + "kind": "flow" + } + ] + }, + { + "from": "rentahuman.create_qa_run_template", + "on": "ok", + "why": "runs happen on the cadence you set; results land as QA runs", + "then": [ + { + "cmd": "pilotctl appstore call io.pilot.rentahuman rentahuman.list_qa_runs '{\"templateId\":\"