agentdraft 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,208 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentdraft
3
+ Version: 0.1.2
4
+ Summary: AgentDraft SDK — ops API for AI agents with inbox, conflict-free calendar booking, and audit trail.
5
+ Author-email: AgentDraft Labs <hello@agentdraft.io>
6
+ License: MIT
7
+ Project-URL: Homepage, https://agentdraft.io
8
+ Project-URL: Documentation, https://agentdraft.io/docs
9
+ Project-URL: Specification, https://agentdraft.io/spec
10
+ Project-URL: Changelog, https://agentdraft.io/changelog
11
+ Project-URL: Repository, https://github.com/ryabinski-labs/agentdraft
12
+ Project-URL: Issues, https://github.com/ryabinski-labs/agentdraft/issues
13
+ Keywords: agentdraft,ai-agents,agents,calendar,scheduling,booking,coordination,langchain,crewai,autogen,mcp
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Topic :: Office/Business :: Scheduling
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: httpx>=0.27.0
30
+ Dynamic: license-file
31
+
32
+ # agentdraft — Python SDK
33
+
34
+ [![PyPI](https://img.shields.io/pypi/v/agentdraft.svg)](https://pypi.org/project/agentdraft/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/agentdraft.svg)](https://pypi.org/project/agentdraft/)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://agentdraft.io/legal/license)
37
+
38
+ **AgentDraft is the coordination layer for AI scheduling agents.** When two
39
+ or more agents — your sales bot, a Cal.com handler, an internal recruiter,
40
+ a Claude/OpenAI assistant — write to the same calendar, AgentDraft is the
41
+ one API that decides who wins, atomically, with a tamper-evident audit row
42
+ for every commit.
43
+
44
+ This is the official Python SDK. It gives an agent a typed, one-line
45
+ surface to participate.
46
+
47
+ - **Protocol spec:** <https://agentdraft.io/spec>
48
+ - **Why it exists:** <https://agentdraft.io/why>
49
+ - **Live API reference:** <https://agentdraft.io/docs>
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install agentdraft
55
+ ```
56
+
57
+ Requires Python 3.9+.
58
+
59
+ ## Upgrade
60
+
61
+ ```bash
62
+ pip install -U agentdraft
63
+ ```
64
+
65
+ Release notes: <https://agentdraft.io/changelog>
66
+
67
+ ## Quickstart
68
+
69
+ ```python
70
+ from datetime import datetime, timedelta, timezone
71
+ from agentdraft import Client, Conflict
72
+
73
+ client = Client(api_key="avs_live_...") # or set AGENTDRAFT_API_KEY
74
+
75
+ start = datetime.now(timezone.utc) + timedelta(hours=4)
76
+ end = start + timedelta(minutes=30)
77
+
78
+ try:
79
+ booking = client.bookings.commit(
80
+ start=start, end=end,
81
+ idempotency_key="ik_call_42",
82
+ title="Discovery call",
83
+ invitee={"name": "Ada Lovelace", "email": "ada@example.com"},
84
+ )
85
+ print("booked:", booking.booking_id)
86
+ except Conflict as e:
87
+ print(f"outranked by {e.winning_agent_id} (rank {e.winning_agent_priority})")
88
+ ```
89
+
90
+ `title` and `invitee` (`{name, email, notes}`, all optional) are first-class
91
+ fields: they're persisted on the booking, echoed back on the returned
92
+ `Booking`, and shown to the calendar owner in the dashboard. A top-level
93
+ `title` supersedes the legacy `metadata={"title": ...}`. `client.agents.me()`
94
+ also returns the owner's `timezone` so a client can render slots in their
95
+ zone.
96
+
97
+ A losing agent gets a typed `Conflict` exception, not a timeout — it
98
+ knows who won, by what priority, and where the audit row lives, so
99
+ fallback behavior (propose an alternate, escalate, defer) is a clean
100
+ `except` clause away.
101
+
102
+ ## Why a separate API?
103
+
104
+ Coordinator frameworks (LangChain, LangGraph, CrewAI, AutoGen, Composio,
105
+ the OpenAI Agents SDK) coordinate *work between agents* — sequential,
106
+ parallel, or graph orchestration of LLM calls and tools. None of them
107
+ solve **write contention on the calendar itself**: two agents firing
108
+ `POST /events` against Google Calendar at the same moment will both
109
+ succeed, and you have a double-booking.
110
+
111
+ AgentDraft solves only that problem, and solves it once. Every agent
112
+ the calendar owner runs calls `bookings.commit(...)` against AgentDraft
113
+ before touching the real calendar. The conflict engine uses
114
+ time-bucketed conditional writes in DynamoDB inside a single
115
+ `TransactWriteItems` — atomic, race-free, no locks. The user ranks
116
+ their agents in the dashboard; ties go to the higher-rank agent;
117
+ recent commits can be evicted by higher-priority agents inside a
118
+ configurable bump window.
119
+
120
+ ## See the race
121
+
122
+ The repo ships a multi-agent race demo. With the local stack up:
123
+
124
+ ```bash
125
+ git clone https://github.com/ryabinski-labs/agentdraft && cd agentdraft
126
+ docker compose up -d dynamodb
127
+ pip install -e ".[dev]" -e sdks/python
128
+ uvicorn app.main:app --port 8080 &
129
+ python scripts/demo_race.py # 5 agents, ranked priorities, one slot
130
+ ```
131
+
132
+ Five agents fire concurrently at the same target slot. The highest-rank
133
+ agent wins; the rest get `409 outranked` with the winner's identity for
134
+ graceful fallback. The demo prints per-agent latency, the winning
135
+ booking id, and a link to the audit trail of the whole race.
136
+
137
+ ## Authentication
138
+
139
+ API keys are issued from the AgentDraft dashboard and start with
140
+ `avs_live_`. Pass it explicitly or let the client read it from the
141
+ environment:
142
+
143
+ ```python
144
+ Client(api_key="avs_live_...")
145
+ # or
146
+ import os; os.environ["AGENTDRAFT_API_KEY"] = "avs_live_..."
147
+ Client()
148
+ ```
149
+
150
+ For local development against a dev backend, point at it via
151
+ `AGENTDRAFT_BASE_URL` or the `base_url=` kwarg.
152
+
153
+ ## Surface
154
+
155
+ | Attribute | Purpose |
156
+ |---|---|
157
+ | `client.availability` | Read merged availability across all agents writing to the calendar |
158
+ | `client.bookings` | `hold`, `release`, `commit`, `cancel` — the four state transitions |
159
+ | `client.agents` | `me()` — confirm key + current priority + scopes |
160
+ | `client.mailbox` | Inbound/outbound mail surface for agents that book via email |
161
+
162
+ All blocking I/O. An async client is on the roadmap; for now wrap with
163
+ `asyncio.to_thread` if you need concurrency.
164
+
165
+ ## Error types
166
+
167
+ Every failure is a typed exception so callers can branch precisely:
168
+
169
+ - `Conflict` — your write was outranked. Carries `winning_booking_id`,
170
+ `winning_agent_id`, `winning_agent_priority`, `your_priority`, and
171
+ `reason`. The winning booking's `audit_event_id` is available on the
172
+ returned `Booking` model for the agent that *did* win.
173
+ - `AuthError` — bad / missing / expired API key.
174
+ - `RateLimited` — token bucket exhausted. Has `retry_after` (seconds).
175
+ - `RuleViolation` — request was syntactically valid but violated a
176
+ rule (focus block, daily cap, business hours).
177
+ - `AgentDraftError` — base class; catch this if you only need a
178
+ catch-all.
179
+
180
+ ## Idempotency
181
+
182
+ Pass `idempotency_key=` to `bookings.commit(...)`. The server caches the
183
+ result by `(agent_id, key)` for 24 hours, so a retry over a flaky network
184
+ returns the original booking, not a duplicate.
185
+
186
+ ## Use with LangChain / CrewAI / AutoGen
187
+
188
+ The SDK is framework-agnostic — wrap any method in a `Tool` and pass it
189
+ to your agent. A first-party `agentdraft-langchain` package with ready
190
+ `BookingTool` / `AvailabilityTool` / `ConflictAwareBookingTool`
191
+ wrappers is on the roadmap.
192
+
193
+ ## Links
194
+
195
+ - Protocol spec: <https://agentdraft.io/spec>
196
+ - API docs: <https://agentdraft.io/docs>
197
+ - Changelog: <https://agentdraft.io/changelog>
198
+ - Source & issues: <https://github.com/ryabinski-labs/agentdraft>
199
+ - TypeScript SDK: [`agentdraft`](https://www.npmjs.com/package/agentdraft)
200
+
201
+ ## Security
202
+
203
+ Found a vulnerability? See <https://agentdraft.io/security> — **do not**
204
+ open a public issue for a security report.
205
+
206
+ ## License
207
+
208
+ MIT — see <https://agentdraft.io/legal/license>.
@@ -0,0 +1,177 @@
1
+ # agentdraft — Python SDK
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/agentdraft.svg)](https://pypi.org/project/agentdraft/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/agentdraft.svg)](https://pypi.org/project/agentdraft/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://agentdraft.io/legal/license)
6
+
7
+ **AgentDraft is the coordination layer for AI scheduling agents.** When two
8
+ or more agents — your sales bot, a Cal.com handler, an internal recruiter,
9
+ a Claude/OpenAI assistant — write to the same calendar, AgentDraft is the
10
+ one API that decides who wins, atomically, with a tamper-evident audit row
11
+ for every commit.
12
+
13
+ This is the official Python SDK. It gives an agent a typed, one-line
14
+ surface to participate.
15
+
16
+ - **Protocol spec:** <https://agentdraft.io/spec>
17
+ - **Why it exists:** <https://agentdraft.io/why>
18
+ - **Live API reference:** <https://agentdraft.io/docs>
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install agentdraft
24
+ ```
25
+
26
+ Requires Python 3.9+.
27
+
28
+ ## Upgrade
29
+
30
+ ```bash
31
+ pip install -U agentdraft
32
+ ```
33
+
34
+ Release notes: <https://agentdraft.io/changelog>
35
+
36
+ ## Quickstart
37
+
38
+ ```python
39
+ from datetime import datetime, timedelta, timezone
40
+ from agentdraft import Client, Conflict
41
+
42
+ client = Client(api_key="avs_live_...") # or set AGENTDRAFT_API_KEY
43
+
44
+ start = datetime.now(timezone.utc) + timedelta(hours=4)
45
+ end = start + timedelta(minutes=30)
46
+
47
+ try:
48
+ booking = client.bookings.commit(
49
+ start=start, end=end,
50
+ idempotency_key="ik_call_42",
51
+ title="Discovery call",
52
+ invitee={"name": "Ada Lovelace", "email": "ada@example.com"},
53
+ )
54
+ print("booked:", booking.booking_id)
55
+ except Conflict as e:
56
+ print(f"outranked by {e.winning_agent_id} (rank {e.winning_agent_priority})")
57
+ ```
58
+
59
+ `title` and `invitee` (`{name, email, notes}`, all optional) are first-class
60
+ fields: they're persisted on the booking, echoed back on the returned
61
+ `Booking`, and shown to the calendar owner in the dashboard. A top-level
62
+ `title` supersedes the legacy `metadata={"title": ...}`. `client.agents.me()`
63
+ also returns the owner's `timezone` so a client can render slots in their
64
+ zone.
65
+
66
+ A losing agent gets a typed `Conflict` exception, not a timeout — it
67
+ knows who won, by what priority, and where the audit row lives, so
68
+ fallback behavior (propose an alternate, escalate, defer) is a clean
69
+ `except` clause away.
70
+
71
+ ## Why a separate API?
72
+
73
+ Coordinator frameworks (LangChain, LangGraph, CrewAI, AutoGen, Composio,
74
+ the OpenAI Agents SDK) coordinate *work between agents* — sequential,
75
+ parallel, or graph orchestration of LLM calls and tools. None of them
76
+ solve **write contention on the calendar itself**: two agents firing
77
+ `POST /events` against Google Calendar at the same moment will both
78
+ succeed, and you have a double-booking.
79
+
80
+ AgentDraft solves only that problem, and solves it once. Every agent
81
+ the calendar owner runs calls `bookings.commit(...)` against AgentDraft
82
+ before touching the real calendar. The conflict engine uses
83
+ time-bucketed conditional writes in DynamoDB inside a single
84
+ `TransactWriteItems` — atomic, race-free, no locks. The user ranks
85
+ their agents in the dashboard; ties go to the higher-rank agent;
86
+ recent commits can be evicted by higher-priority agents inside a
87
+ configurable bump window.
88
+
89
+ ## See the race
90
+
91
+ The repo ships a multi-agent race demo. With the local stack up:
92
+
93
+ ```bash
94
+ git clone https://github.com/ryabinski-labs/agentdraft && cd agentdraft
95
+ docker compose up -d dynamodb
96
+ pip install -e ".[dev]" -e sdks/python
97
+ uvicorn app.main:app --port 8080 &
98
+ python scripts/demo_race.py # 5 agents, ranked priorities, one slot
99
+ ```
100
+
101
+ Five agents fire concurrently at the same target slot. The highest-rank
102
+ agent wins; the rest get `409 outranked` with the winner's identity for
103
+ graceful fallback. The demo prints per-agent latency, the winning
104
+ booking id, and a link to the audit trail of the whole race.
105
+
106
+ ## Authentication
107
+
108
+ API keys are issued from the AgentDraft dashboard and start with
109
+ `avs_live_`. Pass it explicitly or let the client read it from the
110
+ environment:
111
+
112
+ ```python
113
+ Client(api_key="avs_live_...")
114
+ # or
115
+ import os; os.environ["AGENTDRAFT_API_KEY"] = "avs_live_..."
116
+ Client()
117
+ ```
118
+
119
+ For local development against a dev backend, point at it via
120
+ `AGENTDRAFT_BASE_URL` or the `base_url=` kwarg.
121
+
122
+ ## Surface
123
+
124
+ | Attribute | Purpose |
125
+ |---|---|
126
+ | `client.availability` | Read merged availability across all agents writing to the calendar |
127
+ | `client.bookings` | `hold`, `release`, `commit`, `cancel` — the four state transitions |
128
+ | `client.agents` | `me()` — confirm key + current priority + scopes |
129
+ | `client.mailbox` | Inbound/outbound mail surface for agents that book via email |
130
+
131
+ All blocking I/O. An async client is on the roadmap; for now wrap with
132
+ `asyncio.to_thread` if you need concurrency.
133
+
134
+ ## Error types
135
+
136
+ Every failure is a typed exception so callers can branch precisely:
137
+
138
+ - `Conflict` — your write was outranked. Carries `winning_booking_id`,
139
+ `winning_agent_id`, `winning_agent_priority`, `your_priority`, and
140
+ `reason`. The winning booking's `audit_event_id` is available on the
141
+ returned `Booking` model for the agent that *did* win.
142
+ - `AuthError` — bad / missing / expired API key.
143
+ - `RateLimited` — token bucket exhausted. Has `retry_after` (seconds).
144
+ - `RuleViolation` — request was syntactically valid but violated a
145
+ rule (focus block, daily cap, business hours).
146
+ - `AgentDraftError` — base class; catch this if you only need a
147
+ catch-all.
148
+
149
+ ## Idempotency
150
+
151
+ Pass `idempotency_key=` to `bookings.commit(...)`. The server caches the
152
+ result by `(agent_id, key)` for 24 hours, so a retry over a flaky network
153
+ returns the original booking, not a duplicate.
154
+
155
+ ## Use with LangChain / CrewAI / AutoGen
156
+
157
+ The SDK is framework-agnostic — wrap any method in a `Tool` and pass it
158
+ to your agent. A first-party `agentdraft-langchain` package with ready
159
+ `BookingTool` / `AvailabilityTool` / `ConflictAwareBookingTool`
160
+ wrappers is on the roadmap.
161
+
162
+ ## Links
163
+
164
+ - Protocol spec: <https://agentdraft.io/spec>
165
+ - API docs: <https://agentdraft.io/docs>
166
+ - Changelog: <https://agentdraft.io/changelog>
167
+ - Source & issues: <https://github.com/ryabinski-labs/agentdraft>
168
+ - TypeScript SDK: [`agentdraft`](https://www.npmjs.com/package/agentdraft)
169
+
170
+ ## Security
171
+
172
+ Found a vulnerability? See <https://agentdraft.io/security> — **do not**
173
+ open a public issue for a security report.
174
+
175
+ ## License
176
+
177
+ MIT — see <https://agentdraft.io/legal/license>.
@@ -16,27 +16,38 @@ Quickstart::
16
16
  booking = client.bookings.commit(
17
17
  start=start, end=end,
18
18
  idempotency_key="ik_call_42",
19
- metadata={"title": "Discovery call"},
19
+ title="Discovery call",
20
+ invitee={"name": "Ada Lovelace", "email": "ada@example.com"},
20
21
  )
21
22
  print("booked:", booking.booking_id)
22
23
  except Conflict as e:
23
24
  print(f"outranked by {e.winning_agent_id} (rank {e.winning_agent_priority})")
24
25
  """
25
26
 
27
+ __version__ = "0.1.2"
28
+
26
29
  from .client import Client
27
- from .errors import AgentDraftError, AuthError, Conflict, RateLimited, RuleViolation
30
+ from .errors import (
31
+ AgentDraftError,
32
+ ApprovalTimeout,
33
+ AuthError,
34
+ Conflict,
35
+ ConsentRequired,
36
+ RateLimited,
37
+ RuleViolation,
38
+ )
28
39
  from .models import Booking, Hold, Slot
29
40
 
30
41
  __all__ = [
31
42
  "Client",
32
43
  "AgentDraftError",
44
+ "ApprovalTimeout",
33
45
  "AuthError",
34
46
  "Conflict",
47
+ "ConsentRequired",
35
48
  "RateLimited",
36
49
  "RuleViolation",
37
50
  "Booking",
38
51
  "Hold",
39
52
  "Slot",
40
53
  ]
41
-
42
- __version__ = "0.1.0"
@@ -3,15 +3,26 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import time
6
7
  from datetime import datetime
7
8
  from typing import Any, Optional
8
9
 
9
10
  import httpx
10
11
 
11
- from .errors import AgentDraftError, AuthError, Conflict, RateLimited, RuleViolation
12
+ from . import __version__
13
+ from .errors import (
14
+ AgentDraftError,
15
+ ApprovalTimeout,
16
+ AuthError,
17
+ Conflict,
18
+ ConsentRequired,
19
+ RateLimited,
20
+ RuleViolation,
21
+ )
12
22
  from .models import AgentIdentity, Booking, Slot
13
23
 
14
24
  DEFAULT_BASE_URL = "https://api.agentdraft.io"
25
+ DEFAULT_USER_AGENT = f"agentdraft-python/{__version__}"
15
26
 
16
27
 
17
28
  class Client:
@@ -37,7 +48,7 @@ class Client:
37
48
  *,
38
49
  base_url: Optional[str] = None,
39
50
  timeout: float = 10.0,
40
- user_agent: str = "agentdraft-python/0.1.0",
51
+ user_agent: str = DEFAULT_USER_AGENT,
41
52
  ):
42
53
  api_key = api_key or os.environ.get("AGENTDRAFT_API_KEY")
43
54
  if not api_key:
@@ -55,6 +66,7 @@ class Client:
55
66
  self.bookings = _Bookings(self)
56
67
  self.agents = _Agents(self)
57
68
  self.mailbox = _Mailbox(self)
69
+ self.approvals = _Approvals(self)
58
70
 
59
71
  def close(self) -> None:
60
72
  self._http.close()
@@ -90,6 +102,15 @@ class Client:
90
102
  def _raise_for(self, status: int, body: dict, headers: dict) -> None:
91
103
  if status == 401 or status == 403:
92
104
  raise AuthError(body.get("detail") or str(body), status=status, body=body)
105
+ if status == 409 and body.get("error") == "consent_required":
106
+ raise ConsentRequired(
107
+ "consent_required",
108
+ body=body,
109
+ consent_request_id=body.get("consent_request_id"),
110
+ action=body.get("action"),
111
+ hold_id=body.get("hold_id"),
112
+ expires_at=body.get("expires_at"),
113
+ )
93
114
  if status == 409:
94
115
  raise Conflict(
95
116
  body.get("error", "outranked"),
@@ -160,6 +181,9 @@ class _Bookings(_Resource):
160
181
  buffer_after_min: int = 0,
161
182
  bump_window_seconds: Optional[int] = None,
162
183
  metadata: Optional[dict] = None,
184
+ with_conferencing: bool = False,
185
+ title: Optional[str] = None,
186
+ invitee: Optional[dict] = None,
163
187
  ) -> Booking:
164
188
  return self._post(
165
189
  start=start,
@@ -170,6 +194,9 @@ class _Bookings(_Resource):
170
194
  buffer_after_min=buffer_after_min,
171
195
  bump_window_seconds=bump_window_seconds,
172
196
  metadata=metadata,
197
+ with_conferencing=with_conferencing,
198
+ title=title,
199
+ invitee=invitee,
173
200
  )
174
201
 
175
202
  def hold(
@@ -181,6 +208,8 @@ class _Bookings(_Resource):
181
208
  buffer_before_min: int = 0,
182
209
  buffer_after_min: int = 0,
183
210
  metadata: Optional[dict] = None,
211
+ title: Optional[str] = None,
212
+ invitee: Optional[dict] = None,
184
213
  ) -> Booking:
185
214
  return self._post(
186
215
  start=start,
@@ -190,6 +219,8 @@ class _Bookings(_Resource):
190
219
  buffer_before_min=buffer_before_min,
191
220
  buffer_after_min=buffer_after_min,
192
221
  metadata=metadata,
222
+ title=title,
223
+ invitee=invitee,
193
224
  )
194
225
 
195
226
  def release(self, hold_id: str) -> None:
@@ -212,6 +243,12 @@ class _Bookings(_Resource):
212
243
  body["bump_window_seconds"] = kwargs["bump_window_seconds"]
213
244
  if kwargs.get("metadata"):
214
245
  body["metadata"] = kwargs["metadata"]
246
+ if kwargs.get("with_conferencing"):
247
+ body["with_conferencing"] = True
248
+ if kwargs.get("title"):
249
+ body["title"] = kwargs["title"]
250
+ if kwargs.get("invitee"):
251
+ body["invitee"] = kwargs["invitee"]
215
252
 
216
253
  headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
217
254
  _, j, _ = self._c._request("POST", "/v1/bookings", json=body, headers=headers)
@@ -310,3 +347,84 @@ class _Mailbox(_Resource):
310
347
  "GET", f"/v1/mailbox/suppressions/{quote(address, safe='@+.')}"
311
348
  )
312
349
  return j
350
+
351
+
352
+ TERMINAL_APPROVAL_STATUSES = frozenset({"approved", "denied", "expired"})
353
+
354
+
355
+ class _Approvals(_Resource):
356
+ """Human sign-off for anything your agent is about to do (#446).
357
+
358
+ The action need not be an AgentDraft one — gate a deploy, a refund, or an
359
+ outbound email on the same call. Requires the ``approvals:request`` scope.
360
+
361
+ approval = client.approvals.request(
362
+ action_type="deploy.production",
363
+ summary="Ship v2.3.0 to production",
364
+ evidence={"commit": "a1b2c3d", "migrations": 2},
365
+ )
366
+ result = client.approvals.wait(approval["approval_id"])
367
+ if result["status"] != "approved":
368
+ raise SystemExit(f"stood down: {result['status']}")
369
+ """
370
+
371
+ def request(
372
+ self,
373
+ action_type: str,
374
+ summary: str,
375
+ *,
376
+ evidence: Optional[dict] = None,
377
+ ttl_seconds: Optional[int] = None,
378
+ idempotency_key: Optional[str] = None,
379
+ ) -> dict:
380
+ """Open a request and return immediately with ``{approval_id, status,
381
+ expires_at}``. Nothing blocks until you call :meth:`wait`."""
382
+ body: dict[str, Any] = {"action_type": action_type, "summary": summary}
383
+ if evidence is not None:
384
+ body["evidence"] = evidence
385
+ if ttl_seconds is not None:
386
+ body["ttl_seconds"] = ttl_seconds
387
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
388
+ _, j, _ = self._c._request("POST", "/v1/approvals", json=body, headers=headers)
389
+ return j
390
+
391
+ def get(self, approval_id: str) -> dict:
392
+ """Current state of a request, including its resolution once decided."""
393
+ _, j, _ = self._c._request("GET", f"/v1/approvals/{approval_id}")
394
+ return j
395
+
396
+ def wait(
397
+ self,
398
+ approval_id: str,
399
+ *,
400
+ timeout: Optional[float] = None,
401
+ poll_interval: float = 5.0,
402
+ ) -> dict:
403
+ """Block until a human decides, then return the resolved approval.
404
+
405
+ Returns on any terminal status — ``approved``, ``denied``, or
406
+ ``expired`` — so check ``result["status"]`` rather than assuming a
407
+ return means yes.
408
+
409
+ ``timeout`` is a client-side ceiling in seconds. Left unset, the wait
410
+ follows the request's own ``expires_at``: once it lapses the server
411
+ sweeps it to ``expired`` and this returns that, so the default never
412
+ loops forever. :class:`ApprovalTimeout` therefore only fires when *you*
413
+ set a timeout shorter than the request's life — the request stays
414
+ pending and resolvable when it does.
415
+ """
416
+ started = time.monotonic()
417
+ deadline = started + timeout if timeout is not None else None
418
+ while True:
419
+ current = self.get(approval_id)
420
+ if current.get("status") in TERMINAL_APPROVAL_STATUSES:
421
+ return current
422
+
423
+ waited = time.monotonic() - started
424
+ if deadline is not None and time.monotonic() + poll_interval > deadline:
425
+ raise ApprovalTimeout(
426
+ f"approval {approval_id} still pending after {waited:.0f}s",
427
+ approval_id=approval_id,
428
+ waited_seconds=waited,
429
+ )
430
+ time.sleep(poll_interval)
@@ -46,6 +46,49 @@ class Conflict(AgentDraftError):
46
46
  self.reason = reason
47
47
 
48
48
 
49
+ class ConsentRequired(AgentDraftError):
50
+ """409 — a high-impact action is gated on a human approve/deny (#236).
51
+
52
+ The slot is parked as a HOLD until a person decides on the dashboard, so the
53
+ action is neither committed nor lost. ``consent_request_id`` identifies the
54
+ pending decision; ``hold_id`` is the parked hold; ``expires_at`` is when the
55
+ park (and the request) lapse if nobody acts. Distinct from ``Conflict``: you
56
+ did not lose to another agent — you're waiting on a human.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ message: str,
62
+ *,
63
+ status: int = 409,
64
+ body: dict,
65
+ consent_request_id: Optional[str] = None,
66
+ action: Optional[str] = None,
67
+ hold_id: Optional[str] = None,
68
+ expires_at: Optional[str] = None,
69
+ ):
70
+ super().__init__(message, status=status, body=body)
71
+ self.consent_request_id = consent_request_id
72
+ self.action = action
73
+ self.hold_id = hold_id
74
+ self.expires_at = expires_at
75
+
76
+
77
+ class ApprovalTimeout(AgentDraftError):
78
+ """``approvals.wait()`` gave up before a human decided (#446).
79
+
80
+ Client-side only — the request is still pending on the server and a human
81
+ can still resolve it, so treat this as "not yet", not "denied". Poll again
82
+ with :meth:`Client.approvals.get` or wait again; ``approval_id`` is carried
83
+ so you can.
84
+ """
85
+
86
+ def __init__(self, message: str, *, approval_id: str, waited_seconds: float):
87
+ super().__init__(message)
88
+ self.approval_id = approval_id
89
+ self.waited_seconds = waited_seconds
90
+
91
+
49
92
  class RuleViolation(AgentDraftError):
50
93
  """422 — the proposed booking violates a rule (working hours, focus block, etc.)."""
51
94