Metadata-Version: 2.4
Name: stackshift
Version: 1.7.0
Summary: Official Python SDK for StackShift.
Author-email: StackShift <support@stackshift.cloud>
Project-URL: Homepage, https://stackshift.cloud
Project-URL: Documentation, https://docs.stackshift.cloud
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: requests>=2.31

# StackShift Python SDK

Official Python SDK for StackShift.

## Install

```bash
pip install stackshift
```

## Partner platforms

Staff-enabled partner accounts buy domains for merchants from their StackShift wallet. Use an API token with the `partner:manage` scope (and `mail:manage` to run campaigns) on your server:

```python
domain = stackshift.partner.purchase_domain(
    domain="merchantshop.com",
    years=1,
    merchant_ref="m_123",
    registrant={"first_name": "Ada", "last_name": "Obi", "email": "ada@merchantshop.com", "phone": "+2348012345678", "address": "1 Marina", "city": "Lagos", "state": "Lagos", "postal_code": "101001", "country": "NG"},
    brand={"name": "Merchant Shop", "postalAddress": "1 Marina, Lagos, Nigeria"},
    replies={"mode": "forward", "forwardTo": "ada@gmail.com"},
    idempotency_key="order-m_123-1",
)
status = stackshift.partner.get_domain(domain["id"])
```

`partner` also exposes `overview`, `update_settings`, `list_domains` (with `q`, `attention` and `website` to find the merchants that need you), `connect_domain` and `transfer_in_domain` / `transfer_in_connected_domain` / `switch_transfer_nameservers` for domains the merchant already owns, `set_website`, `remove_website`, `check_website` and `list_website_projects` for the storefront, `list_mailboxes`, `create_mailbox`, `update_mailbox`, `create_mailbox_password_link`, `pause_mailbox`, `resume_mailbox`, `remove_mailbox`, `create_alias`, `update_alias` and `remove_alias` for merchant mailboxes, `start_mail_migration`, `get_mail_migration`, `switch_mail_migration`, `cancel_mail_migration`, `retry_mail_migration`, `retry_mail_migration_account` and `skip_mail_migration_account` for moving old email in, `retry_domain`, `get_diagnosis`, `restore_dns`, `transfer_out`, `resume_brand`, `get_complaints`, `list_complaint_sources`, `suppress_never_opened`, `undo_suppression`, `usage`, `list_requests` and `create_request`. `transfer_out` needs the `partner:transfer` scope (or `*`); `partner:manage` alone is refused. Purchases are capped at 30 per minute and, per partner, at 25 domains in a rolling 24 hours (429 `daily_purchase_limit`; staff can raise it). Validation errors are 400, state conflicts are 409. The `brand` and `replies` dictionaries use API camelCase keys.

## Connected agents

An agent that holds a connection credential (issued by the owner, not an account API key) can use the same API the MCP tools and the CLI use:

```python
stackshift = StackShift(api_key=os.environ["STACKSHIFT_CONNECTION_CREDENTIAL"])
before = stackshift.agents.connections.identity().get("before_each_change")
operation = stackshift.agents.connections.operations.propose(
    action="deployment.restart", resource_id=project_id, configuration={},
    reason="Restart after the config fix", idempotency_key="restart-shop-1",
)
waited = stackshift.agents.connections.operations.wait(operation["id"])
while not waited["settled"]:
    waited = stackshift.agents.connections.operations.wait(operation["id"])
```

`agents.connections` has `identity`, `resources`, `capabilities`, `plan`, `task_facts(objective_id)`, `history` and `lower_level(level=..., expected_version=...)`; `agents.connections.operations` has `list`, `propose`, `get`, `wait` and `approval`. `propose` needs an `idempotency_key`: keep it and send the same one to retry. `wait` returns `settled: False` when the owner has not decided yet; call it again instead of proposing again. `approval(id)` says whether the owner has decided, with `review_url` for the owner. `lower_level` only makes the connection stricter; raising a level raises `StackShiftAPIError` with `code` `session_required`.

## Manage the agents you run on StackShift

`hosted_agents` manages agents that run on StackShift from your own code or a script. It is separate from `agents`, which is for agents that hold a connection credential.

```python
stackshift = StackShift(api_key=os.environ["STACKSHIFT_API_KEY"])
agent = stackshift.hosted_agents.create(
    {"name": "Supplies agent", "source": {"template": "inbox-helper", "language": "python"}, "level": "ask_every_time"},
    idempotency_key="supplies-agent-1",
)
stackshift.hosted_agents.secrets.set(agent["id"], "SHOP_API_KEY", os.environ["SHOP_API_KEY"])
stackshift.hosted_agents.budget.set(agent["id"], {"ai_credits_per_month": 200000, "compute_per_day": {"NGN": "5000000"}})
stackshift.hosted_agents.deploy(agent["id"], branch="main")
runs = stackshift.hosted_agents.runs(agent["id"], limit=10)
```

`hosted_agents` has `list`, `get`, `create`, `deploy`, `rollback(id, version)`, `stop(id, reason=None)`, `start`, `delete`, `runs`, `run`, `spend(id, days=None)` and `logs(id, offset=None)`, plus `secrets.set`, `secrets.unset` and `budget.set`. `create` needs an `idempotency_key`: keep it and send the same one to retry. Money amounts are in minor units (kobo or cents). To write the agent itself, use `stackshift-agent`.

## Databases: Branches and Sleep when idle

Branches give you a copy of a database for a test run, a preview or a pull request. `with_branch` makes one, waits until it is ready and always deletes it when the block ends:

```python
with stackshift.databases.branches.with_branch(database_id) as branch:
    run_migrations_and_tests(branch["url"])
```

If the block raises, that error is the one you get back (a failed cleanup is attached as `cleanup_error`); a slow create is waited out before the Branch is deleted (`cleanup_timeout`, default 10 minutes). `branch["url"]` is the shared (pooled) connection string; pass `direct=True` for the direct one. `databases.branches` also has `list`, `create`, `get`, `update`, `delete`, `reset`, `retry` (a failed Branch), `promote`, `operation`, `wait_for_operation`, `url`, `options` (what a new Branch can start from), `changes` and `resume_scheduled_jobs`. `list` also returns your plan `allowance`. `create`, `reset`, `retry`, `promote` and `delete` return an operation; `wait_for_operation` polls it until it succeeds or fails. `reset`, `retry`, `promote` and `delete` send an `Idempotency-Key` (a new one for each call if you do not pass `idempotency_key`). Deleting with `cascade=True` also needs `confirm_name` (the Branch's name), or a `ValueError` is raised before the call is sent. `promote` accepts the parent's name or, when the parent is a Branch, its short name. `databases.sleep`, `databases.wake`, `databases.billing`, `databases.branch_usage`, `databases.pay_branch_usage`, `databases.scheduled_jobs`, `databases.wake_events`, `databases.move_to_instant_branches` and `databases.instant_branches_move` cover Sleep when idle and the move to instant Branches. Refusals raise `StackShiftAPIError` with `code` set to values such as `plan_limit`, `no_capacity`, `outside_window`, `too_large` and `payment_required`. On a Dedicated database, `create` and `retry` first raise `price_confirmation_required` with the price in `error.details["quote"]`; send the same call again with `confirm_charge_minor=error.details["quote"]["charge_minor"]`. `create` also takes `instance_class` to pick the size of a Dedicated Branch, and a Dedicated Branch carries its `cost`. A `plan_limit` error carries `limit`, `current` and `upgrade_url` in `details`.

## AI credits

AI credits are account-scoped. Use the shared client to inspect usage, start a top-up, or select managed billing versus your own provider key:

```python
from stackshift import StackShift

stackshift = StackShift()
balance = stackshift.ai_credits.balance()
usage = stackshift.ai_credits.usage(product="assets", limit=25)
quote = stackshift.ai_credits.quote(1_000, "USD")
checkout = stackshift.ai_credits.checkout(
    quote["id"],
    "unique-checkout-id",
    "paystack",
)
stackshift.ai_credits.set_funding("assets", "managed")
```

Keep account API keys and checkout creation on your server.

## Send email

```python
from stackshift import StackShift

stackshift = StackShift()

message = stackshift.mail.send(
    from_="StackShift <noreply@mail.stackshift.cloud>",
    to="ada@example.com",
    subject="Welcome",
    text="Welcome to StackShift.",
    idempotency_key="welcome:user_123",
)

print(message["id"], message["status"], message["idempotencyStatus"])
```

Inspect message status:

```python
messages = stackshift.mail.messages.list(status="mta_accepted", limit=20)
detail = stackshift.mail.messages.get(messages["data"][0]["id"])
attempts = stackshift.mail.messages.attempts(detail["id"])
logs = stackshift.mail.messages.logs(detail["id"])
```

`mta_accepted` means the message was accepted by StackShift's outbound MTA. It does not mean recipient-MX acceptance, inbox placement, opens, clicks, or spam placement. Recipient records later transition independently to `delayed`, `delivered`, `bounced`, `failed`, `suppressed`, or `complained`.

## Events and webhooks

```python
events = stackshift.mail.events.list(
    type="mail.message.bounced",
    message_id="msg_123",
    limit=20,
)

event = stackshift.mail.events.get("evt_123")
timeline = stackshift.mail.messages.timeline("msg_123")

webhook = stackshift.mail.webhooks.create(
    url="https://example.com/stackshift-mail",
    event_types=["mail.message.bounced", "mail.otp.verified"],
)
print(webhook["id"], webhook["secret"])  # Secret is only returned on create/rotate.

deliveries = stackshift.mail.webhooks.deliveries(webhook["id"], status="failed")
retried = stackshift.mail.webhooks.retry_delivery(deliveries["data"][0]["id"])
```

Verify a webhook signature before processing the payload:

```python
valid = stackshift.mail.webhooks.verify_signature(
    raw_body=request.get_data(),
    signature_header=request.headers.get("StackShift-Signature"),
    timestamp_header=request.headers.get("StackShift-Timestamp"),
    secret=os.environ["STACKSHIFT_WEBHOOK_SECRET"],
)
```

Webhook handlers should be idempotent. StackShift retries non-2xx responses. Delivery events do not include full email bodies or OTP codes by default.

## Mailboxes

Host real mailboxes on your own domain. The mailbox owner gets an invitation by email and sets their own password.

```python
domain = stackshift.mail.mailbox_domains.create("acme.com")
print(domain["records"])
stackshift.mail.mailbox_domains.verify(domain["id"])

mailbox = stackshift.mail.mailboxes.create(
    domain_id=domain["id"],
    local_part="ada",
    owner_email="ada@personal.example",
    plan="basic",
)

stackshift.mail.aliases.create(mailbox_id=mailbox["id"], local_part="sales", send_as=True)
stackshift.mail.routes.create(
    domain_id=domain["id"],
    pattern="orders-*",
    targets=[{"kind": "app", "webhookUrl": "https://app.example.com/inbound"}],
)

devices = stackshift.mail.mailboxes.list_devices(mailbox["id"])
stackshift.mail.mailboxes.suspend(mailbox["id"])
```

`stackshift.mail.groups` manages team addresses, and `stackshift.mail.mailbox_imports` copies mail from Google, Microsoft or any IMAP server.

## Give your agent an email address in one call

Agent inboxes are real addresses on StackShift's shared domain. Create one, let your agent sign up somewhere, wait for the verification code and delete the inbox when the job is done.

```python
inbox = stackshift.mail.agent_inboxes.create(
    prefix="signup",
    labels={"job": "onboarding-42"},
    job_id="onboarding-42",
    expires_in=3600,
    idempotency_key="onboarding-42-inbox",
)

sign_up_somewhere(inbox["address"])

message = stackshift.mail.agent_inboxes.wait_for_message(
    inbox["id"],
    sender="paystack.com",
    timeout=120,
    since=inbox["createdAt"],
)
if message:
    print(message["codes"][0], message["links"])

stackshift.mail.agent_inboxes.finish_job("onboarding-42")
```

`wait_for_message` calls the server wait endpoint in turns of up to 50 seconds until a message arrives or `timeout` (seconds, default 30) is used up, and returns `None` on timeout. Pass `since` (usually `inbox["createdAt"]`) so a message that arrived before you started waiting is found. `delete_many` takes `ids`, `job_id` or `labels`; `finish_job(job_id)` is the same as `delete_many(job_id=...)`. `create_batch` makes up to 100 inboxes at once.

Read mail with `agent_inboxes.messages.list`, `messages.latest(sender=..., subject=...)` and `messages.get`. Find inboxes with `agent_inboxes.list(labels=..., job_id=..., q=..., domain=..., limit=..., cursor=...)`, change them with `update`, remove one with `delete`, and check what your account may do with `limits()` and `pricing()`.

Sending never hides an approval. `send` returns `status` as `sent`, `waiting_approval` or `draft`, plus `pendingReplyId` and `reasons` when a person has to approve first:

```python
result = stackshift.mail.agent_inboxes.send(
    inbox["id"],
    to=["ada@example.com"],
    subject="Re: your question",
    text="Thanks, here is the answer.",
    reply_to_message_id=message["id"],
)
if result["status"] == "waiting_approval":
    print("Waiting for a person to approve", result["pendingReplyId"], result["reasons"])
```

### Team inboxes

Team inboxes are shared mailboxes your people and agents work together. Use the mailbox id:

```python
page = stackshift.mail.team_inboxes.conversations.list(mailbox_id, status="open", assignee="nobody")
thread = page["conversations"][0]
stackshift.mail.team_inboxes.conversations.assign(mailbox_id, thread["threadId"], worker_id=worker_id, expected_revision=thread["revision"])
stackshift.mail.team_inboxes.conversations.add_note(mailbox_id, thread["threadId"], body="Checked the logs, restarting the app.")

waiting = stackshift.mail.team_inboxes.pending_replies.list(mailbox_id)["pendingReplies"]
stackshift.mail.team_inboxes.pending_replies.send(mailbox_id, waiting[0]["id"])

activity = stackshift.mail.team_inboxes.activity.wait(mailbox_id, after=cursor, timeout=30)
cursor = activity["next"]
```

`conversations.reply(mailbox_id, thread_id, draft_email_id=...)` sends a draft you prepared as the inbox, `conversations.set_status` takes `status` and `expected_revision`, `settings.get(mailbox_id)` reads the inbox settings, and `people(mailbox_id)` returns `{"people": [...], "workers": [...]}`: the people (`userId`, `name`, `email`) and workers you can assign conversations to. `activity.wait` checks for new activity after your cursor every 2 seconds (`interval`) until something happens or `timeout` seconds pass. Refusals raise `StackShiftAPIError` with `code` set to values such as `reply_locked`, `revision_conflict`, `reply_already_decided`, `send_in_progress` (409, a send for the same conversation is already running; wait for it to finish) and `mail_allowance_reached`.

To get inbox events at your own endpoint, create a webhook with `mailbox_id` and the `mailbox.conversation.*` events you want, and check it with `stackshift.mail.webhooks.test(webhook_id)`, which returns `{"deliveryId": ..., "eventId": ...}` once the test event is queued (202).

### Inbox webhooks

Inbox events (`mailbox.conversation.*`, `mailbox.agent_inbox.*`, `mailbox.sending.*`) use the same `StackShift-Signature` header as Mail webhooks:

```python
from stackshift import verify_inbox_webhook

raw_body = request.get_data()
if not verify_inbox_webhook(raw_body, request.headers.get("StackShift-Signature"), os.environ["STACKSHIFT_WEBHOOK_SECRET"]):
    return "Invalid signature", 400
event = json.loads(raw_body)
if event["type"] == "mailbox.conversation.received":
    print(event["data"]["conversation"]["subject"])
```

Signatures older than five minutes are refused. Handlers should be idempotent. `InboxWebhookEvent` and the `INBOX_*_EVENT_TYPES` lists describe the payloads.

## Agent agreements

Agent inboxes can ask for quotes, quote, offer times, and accept or decline offers. Every agreement ends in a record you can check from anywhere.

```python
threads = stackshift.mail.agreements.list(inbox_id=inbox["id"], state="waiting_for_you")

sent = stackshift.mail.agreements.propose(
    inbox["id"],
    type="ask_quote",
    to="orders@mills.example",
    items=[{"description": "Flour", "quantity": "200", "unit": "kg"}],
    wanted_by="2026-11-10",
    idempotency_key="flour-nov-1",
)

change = stackshift.mail.agreements.wait_for_change(sent["threadId"])
stackshift.mail.agreements.accept(sent["threadId"], "OFFER_ID", idempotency_key="flour-accept-1")

record = stackshift.mail.agreements.record(sent["threadId"])
print(stackshift.mail.agreements.check(record)["summary"])
print(stackshift.mail.agreements.limits.get(inbox["id"])["usedToday"])
```

Whether an agent may send on its own depends on the limits the owner sets for the inbox. Anything outside them waits for the owner's approval. Limits are changed in the dashboard.

## Send a template

Templates are rendered by StackShift servers. The Python SDK only calls the REST API.

```python
stackshift.mail.templates.create(
    name="Welcome Email",
    slug="welcome-email",
    subject="Welcome, {{name}}",
    text="Welcome, {{name}}",
)

preview = stackshift.mail.templates.preview(
    "welcome-email",
    data={"name": "Ada"},
)

message = stackshift.mail.send_template(
    template="welcome-email",
    to="ada@example.com",
    from_="Acme <noreply@acme.com>",
    data={"name": "Ada"},
    idempotency_key="welcome:user_123",
)
```

Missing variables fail before a message is queued. Template sends use the same sender-domain, suppression, idempotency, Durable Jobs, and Postfix handoff pipeline as `mail.send`.

## Bounces and suppressions

```python
suppressions = stackshift.mail.suppressions.list()

manual = stackshift.mail.suppressions.create(
    email="bad@example.com",
    reason="manual",
)

stackshift.mail.suppressions.delete(manual["id"])

bounces = stackshift.mail.bounces.list(type="hard")
message_bounces = stackshift.mail.messages.bounces("msg_123")
```

Hard bounces are automatically suppressed by the backend. Suppressions are workspace scoped and checked before a message is queued.

## Verify a sending domain

```python
domain = stackshift.mail.domains.create("acme.com")
print(domain["records"])

stackshift.mail.domains.verify(domain["id"])

message = stackshift.mail.send(
    from_="Acme <noreply@acme.com>",
    to="user@example.com",
    subject="Welcome",
    html="<h1>Welcome</h1>",
    text="Welcome",
)
```

Add the returned SPF, DKIM, and return-path DNS records before verification. DMARC is recommended unless your environment sets `MAIL_DMARC_REQUIRED=true`. DNS propagation can take time.

## Upload an asset

```python
from stackshift import StackShift

stackshift = StackShift()

asset = stackshift.assets.upload(
    "avatar.png",
    folder="avatars",
    visibility="public",
    metadata={"user_id": "user_123"},
)

print(asset["url"])
```

You do not pass a deployed StackShift project ID. The API key identifies the StackShift account, and StackShift resolves the default asset space internally.

## Private asset URL

```python
signed = stackshift.assets.signed_url(
    asset["id"],
    expires_in="10m",
    max_downloads=1,
)

print(signed["url"])
```

## Image transformations

Use built-in presets or create your own named transformations.

```python
from stackshift import StackShift, asset_transform_options, get_asset_transform_preset

stackshift = StackShift()
hero = get_asset_transform_preset("hero")
hero_options = asset_transform_options(hero)

stackshift.assets.create_transformation(hero["name"], **hero_options)

named = stackshift.assets.named_url("asset_123", hero["name"])
signed = stackshift.assets.signed_transform_url("asset_123", **hero_options, expiresIn="10m")

stackshift.assets.delete_transformation("old-preset")
print(named, signed["url"])
```

## Direct browser uploads

Create the upload session on your Python backend:

```python
upload = stackshift.assets.signed_upload_url(
    bucket="avatars",
    key="users/user_123.png",
    visibility="public",
    expiresIn="10m",
    maxBytes=5_000_000,
)
```

Then upload the browser `File` to `upload["url"]` from your frontend. Do not expose your StackShift API key to browser code.

The SDK only talks to the StackShift REST API. Storage placement, replication, disks, and repair are StackShift internals.

### Campaign drafts and engagement

`mail.campaigns.create(content={...})` accepts a private campaign content snapshot; template/version inputs still work. Save the complete draft with `update(id, revision=revision, **draft)` (draft keys use API camelCase). `preview`, `test`, `send(id, revision=revision)`, and `schedule(id, send_at, revision=revision)` protect the reviewed content. Stale revisions return 409.

Send methods accept `tracking={"opens": False, "clicks": True}`. Omitted fields inherit workspace defaults. `mail.tracking` exposes `settings`, `update_settings`, `message`, `campaign`, and branded-domain methods. Deployment capability flags control availability. Opens are approximate; detailed activity expires after 90 days. OTPs and tests never track.

### Isolated testing, brands, migration, and spending

Use a `sspat_test_` credential and `simulation={"scenario": "delayed"}` for a 60-second simulated delay. Delivered, bounced, and complained outcomes are also supported. Test resources are isolated; no real email is delivered.

```python
brand = client.mail.brands.create(name="Product", editable_fields=["company"])
audience = client.mail.audiences.create(brand_id=brand["id"], name="Product news")
```

Audience creation requires an explicit `brand_id`. Shared profile fields belong to the brand; consent stays per audience. `mail.automations`, `mail.forms`, and `mail.segments` expose create/list methods; their input dictionaries use API camelCase keys.

`mail.migration` supports public DNS audits/plans and authenticated checklists. `mail.billing.summary()` and `mail.campaigns.estimate(id)` expose allowance and credit. Pass `approved_max_kobo=0` (or another approved maximum) alongside `revision` when sending/scheduling campaigns. Billing remains inactive until a rate card is explicitly published and activated; funding and spending opt-in are separate owner-only dashboard actions.

## Native video, governed DAM and product rendering

Use the space-scoped DAM and video clients for collaborators, metadata schemas, bucket governance, reviewed publications, picker capabilities, model preparation, product rendering, galleries, version-pinned encoding, captions and playback sessions.

```python
dam = sdk.assets.dam.for_space(space_id)
video = sdk.assets.video.for_space(space_id)
schema = dam.create_metadata_schema({"name": "Products", "fields": [{
    "key": "product_name", "label": "Product name", "type": "text", "required": True,
}]})
published = dam.publish_metadata_schema(schema["id"], schema["revision"])
workspace = video.workspace(asset_id)
```

Mutations carry the supplied revision in `If-Match`. Model preparation and rendering carry an idempotency key; render submission also carries the accepted maximum units. Playback renewal and events authenticate with the session credential, not the account key. Keep these clients on your backend and return grants with `Cache-Control: private, no-store`.

See the [Assets SDK guide](https://docs.stackshift.cloud/assets/sdk-media-workflows) and its linked feature guides for complete language examples, inputs, permissions, review transitions and browser callbacks.

## Assets storage and Cloudinary migration

`sdk.assets.platform.for_space(space_id)` exposes `connections`, `storage`, and `migrations`. Use a verified Cloudinary connection, run a dry migration first, inspect its report, then start the transfer with the same folder prefix and conflict policy.

```python
platform = sdk.assets.platform.for_space(space_id)
preview = platform.migrations.start({"source_connection_id": connection_id,
    "destination_bucket_id": bucket_id, "source_prefix": "campaigns/2026", "dry_run": True})
report = platform.migrations.report(preview["id"])
```

## Support action receivers (unreleased)

`ActionManifest`, `ActionDescriptor`, installation consent types and `validate_action_catalog` describe `stackshift.support.app/v2`. V1 remains actionless. Registered writes require idempotency, a reconciliation endpoint and a registered read verifier; installations explicitly select `action_operations`. These SDK helpers perform no registration or network calls.

Use `receive_action(raw_bytes, headers, store=..., execute=..., reconcile=..., verification=...)`. Verification requires `key_id`, `secret` (the installation action secret, not webhook secret), `installation_id`, `actions`, and a trusted `expected_customer_external_id`. Do not derive that expectation from unverified request JSON. Each synchronous handler returns `(state, result_dict, optional_safe_error_code)`. Success output is checked against the registered flat schema. Receipt helpers `encode_receipt`, `sign_action`, and `verify_receipt` are also exported.

`SQLiteActionStore('/absolute/path/claims.sqlite')` uses standard-library sqlite3, WAL and FULL synchronization. Never use an in-memory database for restart safety. The runnable stdin receiver in `examples/support_action_receiver.py` intentionally returns `demo_not_implemented`; replace its handlers with authorized customer-bound business transactions. Set `ACTION_KEY_ID`, `ACTION_SECRET`, `ACTION_INSTALLATION_ID`, and trusted `EXPECTED_CUSTOMER_EXTERNAL_ID`:

```sh
PYTHONPATH=. python examples/support_action_receiver.py /absolute/path/claims.sqlite headers.json actions.json < body.json
.venv/bin/python -m unittest discover -s tests -p test_support_actions.py -v
```

Send the returned `body` unchanged with `headers`. Execution retries retain exact original bytes and request ID with fresh delivery IDs. A handler error is stored as unknown; running/unknown work is never dispatched again. Reconciliation is a separate handler with empty arguments and the original target request ID, and must query actual downstream state. Accepted results remain replayable; there is no asynchronous callback endpoint yet. Disk claims do not make remote side effects atomic: use downstream idempotency or one transactional business/receipt store, and preserve uncertain claims for reconciliation/human review. This is a single-host SQLite example; distributed receivers need a shared durable store.

See `../support-actions-conformance/README.md` for exact signatures and adapter requirements. Seven action tests pass, including shared vectors, two-connection concurrency, restart and fenced completion. The existing virtualenv is required here because system Python lacks the SDK's declared `requests` dependency. No live installation/provider qualification was performed; unreleased.
