Metadata-Version: 2.4
Name: 1m-handshake
Version: 0.1.7
Summary: Client-side addon for creating verified 1M Agent Handshake registry claims
License-Expression: MIT
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: a2a-sdk<2,>=1.1
Requires-Dist: azure-identity<2,>=1.19
Requires-Dist: click<9,>=8.1
Requires-Dist: httpx<1,>=0.27
Requires-Dist: x402[evm]<3,>=2.18
Requires-Dist: keyring<26,>=25
Requires-Dist: cryptography<46,>=43
Provides-Extra: dev
Requires-Dist: pytest>=8.3; extra == "dev"
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
Requires-Dist: respx>=0.21; extra == "dev"
Dynamic: license-file

# 1M Handshake Addon

The free client-side addon for the 1M Agent Handshake registry. It connects your organization's A2A-capable agent to the registry and supplies proof when your agent makes a real call to another organization's public AgentCard.

Installing and using the addon is free. A fee is charged only when a credit is redeemed to create a permanent, public handshake record in the registry.

## Install

```sh
pip install 1m-handshake
```

## Set up your organization

Run the install command once for your organization:

```sh
1m-handshake install
```

The addon uses organization metadata supplied by a compatible agent host when it is available. Otherwise, it asks the owner for the organization's public domain, then first tries a configured enterprise agent identity. If that is unavailable, it guides the owner through one-time work-email or DNS-TXT domain verification.

By default, setup also installs a visible `1M Handshake` skill in the agent workspace. It gives the agent a voluntary capability to consider a claim after its own meaningful A2A interactions; it is not a listener, does not observe traffic, and never modifies `AGENTS.md`, `CLAUDE.md`, or other broad instructions. Run setup from the workspace root, or specify it directly:

```sh
1m-handshake install --workspace /path/to/agent-workspace
```

Use `--no-agent-skill` only when you want verification without capability installation.

The default endpoint is the public Freshstart testing service. Invited pilot
testers can use `--staging` instead of writing the staging URL. Use
`--backend-url` only when you have been given a different environment URL;
`--staging` and `--backend-url` cannot be combined.

```sh
1m-handshake install --staging
```

## Staging payment test

This optional, resettable test uses a local disposable Base Sepolia wallet. It
never uses real money, requires owner approval by default, and is not a
production approval or permanent public record.

```sh
1m-handshake staging-status
1m-handshake staging-claim counterparty.example
```

`staging-claim` makes a real A2A call only after confirmation, then performs a
one-cent test settlement and records the result in staging. The addon keeps
only privacy-safe local pilot milestones (for example, “funding ready” or
“record redeemed”); it never logs wallet keys, prompts, agent messages, or
call contents.

## Create a handshake

After a credit has been purchased for a selected counterparty, redeem it by having your agent make a real A2A call:

```sh
1m-handshake on-demand <credit-code>
```

You can also use an eligible prepaid credit for a chosen organization:

```sh
1m-handshake use-credit counterparty.example
```

The addon never stores wallet or payment-provider credentials. Autonomous x402 redemption is opt-in and requires a signer supplied by the host integration.

## Service agents (inbound)

A service agent that receives jobs from customers can record them as handshakes. The service is the party that wants the record (Org A); the customer is the counterparty. Wrap the service's own a2a-sdk request handler:

```python
from handshake_addon.inbound import InboundHandshakeHandler
from handshake_addon.local_store import LocalStore

handler = InboundHandshakeHandler(
    inner=my_request_handler,
    org_a_domain="myservice.example",
    store=LocalStore(),
    customer_domain=resolve_customer,
    # context_for_customer=context_for,   # optional, see "Restarts"
    # check_schedule_seconds=(60, 600, 3600, 21600, 86400),   # default
    # resolver_timeout_seconds=1.0,                           # default
)
# pass `handler` wherever the service passed `my_request_handler`
```

The wrapper calls `inner` with identical arguments and returns or yields identical objects. Observation runs in background tasks after the reply is available, so the customer's reply is never held back waiting for it, and the reply is never altered. The customer never sees any 1MAH text. Exceptions raised by `inner` propagate unchanged. One caveat on latency: the `LocalStore` reads and writes run on the event-loop thread, and SQLite waits up to its default 5 s busy timeout for a lock. If the `1m-handshake` CLI or the outbound interceptor holds the lock at that moment, the event loop (and so other requests) can stall for up to that long. That is fine at pilot scale.

`org_a_domain` must be the domain you passed to `1m-handshake install`. It is lowercased, and if it differs from the stored domain the handler logs a warning naming both, because `status` and `confirm` would not see anything it records.

Use the same `HANDSHAKE_ADDON_HOME` as the `1m-handshake` CLI (default `~/.1m-handshake`), so the CLI sees what the handler records. On shutdown, `await handler.aclose()` cancels background tasks. `await handler.drain()` waits for in-flight observations (useful in tests).

Run the wrapped handler in a single worker process. Each handler instance has its own boot id, so in a multi-worker server another worker's first call would relabel this worker's live pending jobs as "Server restarted". Check-back contexts and the SDK's default in-memory task store are also per process.

### customer_domain

`customer_domain(params, context)` is your function. It receives the `SendMessageRequest` and the `ServerCallContext` and returns the customer's hostname, or `None`. A job is skipped when it returns `None`, raises, takes longer than `resolver_timeout_seconds` (1 s), or returns something that is not a dotted domain name such as `customer.example` (single-label names and IP addresses are rejected; it is lowercased). Prefer an async function. A sync function runs inline on the event loop, so it must not block, and the timeout cannot interrupt it.

### What gets recorded

- A first job that ends `completed`, or is answered with a message, is stored as a recordable proof.
- Any other first outcome (still running, input required, failed and so on) becomes a pending entry. A pending entry is never recordable.
- `1m-handshake status` lists recordable customers under "Recordable:" (inbound ones are marked "(inbound)") and pending jobs under "Pending:" with the reason, first seen, last checked and next check.
- `1m-handshake confirm <customer-domain>` records a recordable customer. It can start a payment flow (a held credit, your autonomous or assisted payment setup, or a checkout link). A customer that is only pending has no proof, so `confirm` refuses it with the pending reason, before any payment starts, until the job completes.
- A customer already recorded (by either direction) is never listed as pending.
- Pending reasons: the job is still running; waiting for the customer's input; waiting for the customer to authenticate (these three end in "will check again" while a check-back is running, and in "no longer checking automatically." when none is); the job ended as failed, rejected or canceled (a later completed job from that customer can still be recorded); "No longer checking; job state unknown"; "Server restarted; couldn't confirm whether this job finished."; "Job is still running after the restart; no longer checking automatically."; "Job lost with the restart; outcome unknown."
- A pending entry with no check-back running never blocks the customer: a later job from them gets its own pending entry or proof.

### Check-back

Check-back runs for every pending job that has not ended, including jobs waiting for the customer's input or authentication; it does not run for jobs that ended failed, rejected or canceled. The handler asks `inner.on_get_task` at 60 s, 10 min, 1 h, 6 h and 24 h after it first saw the job (`check_schedule_seconds`). A job that has completed is promoted to recordable. When the schedule is exhausted and the job has still not completed, the entry stays pending with the reason "No longer checking; job state unknown". The customer's own `get_task` or subscribe calls promote a job only when they return `completed`; any other final state they show (input or authentication required, failed, rejected, canceled) updates the pending reason. Check-back uses the original call's context, kept in memory only and never persisted.

### Restarts

Pending entries record which handler instance created them. When a new instance handles its first observing call (`send_message`, streaming `send_message`, `get_task` or subscribe; other methods do not trigger it), it relabels pending jobs from the previous run as "Server restarted; ..." and stops tracking them. Nothing happens at construction. With the SDK's default in-memory task store, jobs do not survive a restart, so they stay relabelled. To re-check them actively you need both a persistent task store and the optional `context_for_customer(customer_domain)` hook, which returns a `ServerCallContext` (or `None`, sync or async, same 1 s timeout) that lets `inner` look up that customer's task. Even then, a job that is still running at restart is re-checked once at startup, not repeatedly; the re-check records what it finds (completed is promoted, an ended job shows how it ended, a running job says it is still running). A job the task store no longer knows is labelled "Job lost with the restart; outcome unknown."

### Limits

- Push-notification completion is not observed. Completion is noticed by check-back and by the customer's own polling or subscribe calls.
- A customer's own `get_task` result that arrives while the very first observation is still resolving can be missed until the next check-back.
- `LocalStore` is a single SQLite file and is not meant for heavy multi-worker concurrency (run one worker, see above). A busy lock can cause one capture to be missed. It never breaks a reply.
- Inbound claims are self-reported. Nothing can corroborate that the customer sent the job. The public record page shows "Job received from <customer>".

## License

MIT. See [LICENSE](LICENSE).
