actionlayer 0.7.0__py3-none-any.whl
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.
- actionlayer-0.7.0.dist-info/METADATA +133 -0
- actionlayer-0.7.0.dist-info/RECORD +7 -0
- actionlayer-0.7.0.dist-info/WHEEL +4 -0
- actionlayer-0.7.0.dist-info/entry_points.txt +3 -0
- actionlayer_mcp/__init__.py +18 -0
- actionlayer_mcp/client.py +317 -0
- actionlayer_mcp/server.py +1112 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: actionlayer
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: MCP server for ActionLayer — give Claude Code and other MCP hosts access to ActionLayer's browser-action catalog with per-session credential intake.
|
|
5
|
+
Project-URL: Homepage, https://actionlayer.io
|
|
6
|
+
Project-URL: Repository, https://github.com/grimjjow/actionlayer
|
|
7
|
+
Author: ActionLayer
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: actionlayer,agents,claude,claude-code,mcp,tools
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: fastmcp>=0.4.0
|
|
20
|
+
Requires-Dist: httpx>=0.27.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# actionlayer
|
|
24
|
+
|
|
25
|
+
<!-- mcp-name: io.actionlayer/actionlayer -->
|
|
26
|
+
|
|
27
|
+
MCP server for [ActionLayer](https://actionlayer.io) — let Claude Code (and any MCP host) drive browser-based sites on your behalf.
|
|
28
|
+
|
|
29
|
+
> Previously published as `actionlayer-mcp`. That project is no longer
|
|
30
|
+
> maintained and stopped at 0.6.0 — install `actionlayer` instead.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
Get an API key from your ActionLayer dashboard at https://actionlayer.io, then:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
claude mcp add --scope user actionlayer "uvx" "actionlayer" \
|
|
38
|
+
--env ACTIONLAYER_API_KEY="ak_…" \
|
|
39
|
+
--env ACTIONLAYER_API_URL="https://api.actionlayer.io"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Restart Claude Code and ask it to do something for you.
|
|
43
|
+
|
|
44
|
+
## Usage from other MCP hosts
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
actionlayer
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Works with Cursor, Cody, Codex, etc.
|
|
51
|
+
|
|
52
|
+
Env vars:
|
|
53
|
+
- `ACTIONLAYER_API_KEY` — required
|
|
54
|
+
- `ACTIONLAYER_API_URL` — defaults to `https://api.actionlayer.io`
|
|
55
|
+
|
|
56
|
+
## Changelog
|
|
57
|
+
|
|
58
|
+
### 0.7.0
|
|
59
|
+
|
|
60
|
+
Renamed: published as `actionlayer` on PyPI from this release on
|
|
61
|
+
(`actionlayer-mcp` is orphaned and frozen at 0.6.0). No code changes.
|
|
62
|
+
The command is now `actionlayer`; `actionlayer-mcp` remains as an
|
|
63
|
+
alias. Includes everything from the unreleased 0.6.1.
|
|
64
|
+
|
|
65
|
+
### 0.6.1
|
|
66
|
+
|
|
67
|
+
No code changes. Embeds the MCP Registry `mcp-name` ownership token
|
|
68
|
+
so the package can be listed at registry.modelcontextprotocol.io.
|
|
69
|
+
|
|
70
|
+
### 0.4.2
|
|
71
|
+
|
|
72
|
+
Internal taxonomy scrub. No behavior change; tool descriptions and
|
|
73
|
+
`next_action.prompt` fallbacks rephrased so calling agents don't
|
|
74
|
+
surface ActionLayer's internal terminology in their own user-facing
|
|
75
|
+
text.
|
|
76
|
+
|
|
77
|
+
### 0.4.1
|
|
78
|
+
|
|
79
|
+
Payment-card approval flow (Phase 2 of the agent payments plan).
|
|
80
|
+
|
|
81
|
+
- `actionlayer_reply` now accepts `payment_approved: bool` for
|
|
82
|
+
tickets whose info_request includes `payment_status`. Users
|
|
83
|
+
approve a charge against their card-on-file with one call.
|
|
84
|
+
- `actionlayer_get_task` / `actionlayer_next_action` surface
|
|
85
|
+
`card_brand`, `card_last4`, `amount_usd`, and `payment_status`
|
|
86
|
+
on the info_request payload so an agent can prompt the user
|
|
87
|
+
with "Approve $X.XX charge on your <brand> card ending <last4>?"
|
|
88
|
+
- The card PAN itself NEVER crosses the MCP boundary. The charge
|
|
89
|
+
is completed against the user's card on file; the PAN never
|
|
90
|
+
appears on agent-visible surfaces. This is by design per
|
|
91
|
+
`docs/agent_payments_plan.md`.
|
|
92
|
+
|
|
93
|
+
### 0.4.0
|
|
94
|
+
|
|
95
|
+
**Backwards-incompatible — public API taxonomy changed.**
|
|
96
|
+
|
|
97
|
+
- Ticket `state` collapses the in-flight phases into a single
|
|
98
|
+
public value `pending`. Polling code that branched on the
|
|
99
|
+
individual sub-phases (e.g. `if state == "executing"`) must
|
|
100
|
+
switch to `state == "pending"`. Terminal states (`completed`,
|
|
101
|
+
`failed`, `cancelled`) and `blocked_on_user` are unchanged.
|
|
102
|
+
- `live_view_url` is no longer returned by the public tools
|
|
103
|
+
(`actionlayer_invoke_action`, `actionlayer_get_action_ticket`).
|
|
104
|
+
User-facing callers should drive the task via polling
|
|
105
|
+
`actionlayer_get_action_ticket` and responding to
|
|
106
|
+
`blocked_on_user` prompts.
|
|
107
|
+
- `actionlayer_invoke_action` sync response now uses
|
|
108
|
+
`outcome="queued"` (instead of `outcome="blocked"`) when the
|
|
109
|
+
task is taking longer than the sync window allows. Callers
|
|
110
|
+
handling `outcome` need to add a `queued` branch: poll
|
|
111
|
+
`actionlayer_get_action_ticket` for terminal state.
|
|
112
|
+
- Terminal tickets now carry a free-text `reason` field summarising
|
|
113
|
+
the outcome (derived from `result.summary` on completed, `error`
|
|
114
|
+
on failed). Pre-existing `result` / `error` fields preserved
|
|
115
|
+
for callers that want the raw structured payload.
|
|
116
|
+
|
|
117
|
+
### 0.3.1
|
|
118
|
+
- Update default API URL to `https://api.actionlayer.io`
|
|
119
|
+
(`api.action-layer.dev` still redirects, but the new host is the
|
|
120
|
+
current canonical name).
|
|
121
|
+
|
|
122
|
+
### 0.3.0
|
|
123
|
+
- Browser-only catalog. Per-session credential intake.
|
|
124
|
+
|
|
125
|
+
### 0.2.0
|
|
126
|
+
- Async-by-default dispatch for browser actions + ticket polling tool.
|
|
127
|
+
|
|
128
|
+
### 0.1.0
|
|
129
|
+
- Initial release.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
actionlayer_mcp/__init__.py,sha256=zqVf1Ww5J2y73Ni2aoOYywLAwmsK_7mRfnZx-U2XYaA,625
|
|
2
|
+
actionlayer_mcp/client.py,sha256=R81e89aq0-xWZcH_bYAUhk0M7sllyU3naQ4E-MbWGqg,11662
|
|
3
|
+
actionlayer_mcp/server.py,sha256=xKmUsy9XjhURknS2mxvPD7wYxbdfd6mWrbOTvVWD2Ws,46130
|
|
4
|
+
actionlayer-0.7.0.dist-info/METADATA,sha256=p7I_ymyxk0DBCnCxk99mafANHLpdcxCemmqkWQ_xhmA,4728
|
|
5
|
+
actionlayer-0.7.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
6
|
+
actionlayer-0.7.0.dist-info/entry_points.txt,sha256=Y8QmcYfYbimW9Um0yhcnL4u0MjQiKSeICPHsOo_i4F8,106
|
|
7
|
+
actionlayer-0.7.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""ActionLayer MCP server.
|
|
2
|
+
|
|
3
|
+
A thin Model Context Protocol shim that translates MCP tool calls into
|
|
4
|
+
ActionLayer REST API calls. Runs on the user's machine (or wherever
|
|
5
|
+
their agent host runs); reads ACTIONLAYER_API_KEY + ACTIONLAYER_API_URL
|
|
6
|
+
from the environment.
|
|
7
|
+
|
|
8
|
+
Distributed via PyPI as `actionlayer` (formerly `actionlayer-mcp`,
|
|
9
|
+
orphaned at 0.6.0). The console_scripts entry point lets agent hosts
|
|
10
|
+
run it as `uvx actionlayer` or `pipx run actionlayer` with no clone
|
|
11
|
+
needed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from actionlayer_mcp.client import ActionLayerClient, ApiError
|
|
15
|
+
|
|
16
|
+
__version__ = "0.7.0"
|
|
17
|
+
|
|
18
|
+
__all__ = ["ActionLayerClient", "ApiError", "__version__"]
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""HTTP client wrapping the ActionLayer REST API.
|
|
2
|
+
|
|
3
|
+
A small async wrapper kept separate from server.py so it's reusable
|
|
4
|
+
(future SDK can import it). All network IO lives here; server.py is
|
|
5
|
+
pure protocol translation.
|
|
6
|
+
|
|
7
|
+
Auth: bearer token from ACTIONLAYER_API_KEY env var. Errors are mapped
|
|
8
|
+
to ApiError with the upstream status + JSON detail string when present.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import uuid
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
DEFAULT_API_URL = "https://api.actionlayer.io"
|
|
20
|
+
USER_AGENT = "actionlayer-mcp/0.7.0"
|
|
21
|
+
DEFAULT_TIMEOUT = httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ApiError(RuntimeError):
|
|
25
|
+
"""Raised when the ActionLayer API returns a non-2xx response."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, status: int, detail: str):
|
|
28
|
+
super().__init__(f"ActionLayer API {status}: {detail}")
|
|
29
|
+
self.status = status
|
|
30
|
+
self.detail = detail
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ActionLayerClient:
|
|
34
|
+
"""Thin async wrapper. One instance per MCP server lifetime."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
api_key: Optional[str] = None,
|
|
40
|
+
api_url: Optional[str] = None,
|
|
41
|
+
):
|
|
42
|
+
self.api_key = api_key or os.environ.get("ACTIONLAYER_API_KEY", "")
|
|
43
|
+
self.api_url = (
|
|
44
|
+
api_url
|
|
45
|
+
or os.environ.get("ACTIONLAYER_API_URL")
|
|
46
|
+
or DEFAULT_API_URL
|
|
47
|
+
).rstrip("/")
|
|
48
|
+
if not self.api_key:
|
|
49
|
+
raise RuntimeError(
|
|
50
|
+
"ACTIONLAYER_API_KEY env var not set. Run "
|
|
51
|
+
"`actionlayer configure --api-key ak_...` to set it up."
|
|
52
|
+
)
|
|
53
|
+
self._client = httpx.AsyncClient(
|
|
54
|
+
base_url=self.api_url,
|
|
55
|
+
headers={
|
|
56
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
57
|
+
"User-Agent": USER_AGENT,
|
|
58
|
+
},
|
|
59
|
+
timeout=DEFAULT_TIMEOUT,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def aclose(self) -> None:
|
|
63
|
+
await self._client.aclose()
|
|
64
|
+
|
|
65
|
+
# --- Tasks --------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
async def start_task(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
goal: str,
|
|
71
|
+
max_budget_usd: Optional[float] = None,
|
|
72
|
+
webhook_url: Optional[str] = None,
|
|
73
|
+
idempotency_key: Optional[str] = None,
|
|
74
|
+
target_url: Optional[str] = None,
|
|
75
|
+
credentials_source: Optional[str] = None,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
# An auto-generated idempotency key gives the API a stable replay
|
|
78
|
+
# surface, but each call still gets a fresh ticket — true
|
|
79
|
+
# deduplication requires the agent to pass its own key, which is
|
|
80
|
+
# the right design (only the agent knows whether two same-arg
|
|
81
|
+
# calls are "retry" or "do it again").
|
|
82
|
+
# Same `al-` prefix as the SDK so a retry that crosses the
|
|
83
|
+
# SDK<->MCP shim boundary still de-dupes on the server side.
|
|
84
|
+
# SEC-MCP-010.
|
|
85
|
+
key = idempotency_key or f"al-{uuid.uuid4()}"
|
|
86
|
+
body: dict[str, Any] = {"goal": goal}
|
|
87
|
+
if max_budget_usd is not None:
|
|
88
|
+
body["max_budget_usd"] = max_budget_usd
|
|
89
|
+
if webhook_url is not None:
|
|
90
|
+
body["webhook_url"] = webhook_url
|
|
91
|
+
if target_url is not None:
|
|
92
|
+
body["target_url"] = target_url
|
|
93
|
+
if credentials_source is not None:
|
|
94
|
+
body["credentials_source"] = credentials_source
|
|
95
|
+
return await self._post("/tasks", json=body, headers={"Idempotency-Key": key})
|
|
96
|
+
|
|
97
|
+
async def get_task(self, ticket_id: str) -> dict[str, Any]:
|
|
98
|
+
return await self._get(f"/tasks/{ticket_id}")
|
|
99
|
+
|
|
100
|
+
async def reply_task(
|
|
101
|
+
self,
|
|
102
|
+
ticket_id: str,
|
|
103
|
+
*,
|
|
104
|
+
message: Optional[str] = None,
|
|
105
|
+
field: Optional[str] = None,
|
|
106
|
+
value: Optional[str] = None,
|
|
107
|
+
sensitive: bool = False,
|
|
108
|
+
info: Optional[dict[str, Any]] = None,
|
|
109
|
+
) -> dict[str, Any]:
|
|
110
|
+
"""Forward a reply to /tasks/{id}/reply.
|
|
111
|
+
|
|
112
|
+
`info` is the info-required payload — pass-through to the
|
|
113
|
+
`info` field on ReplyTaskRequest. Required when the ticket
|
|
114
|
+
carries an `info_request` (set when ActionLayer paused to collect info from the user);
|
|
115
|
+
the API 422s otherwise.
|
|
116
|
+
"""
|
|
117
|
+
body: dict[str, Any] = {"sensitive": sensitive}
|
|
118
|
+
if message is not None:
|
|
119
|
+
body["message"] = message
|
|
120
|
+
if field is not None:
|
|
121
|
+
body["field"] = field
|
|
122
|
+
if value is not None:
|
|
123
|
+
body["value"] = value
|
|
124
|
+
if info is not None:
|
|
125
|
+
body["info"] = info
|
|
126
|
+
return await self._post(f"/tasks/{ticket_id}/reply", json=body)
|
|
127
|
+
|
|
128
|
+
async def cancel_task(self, ticket_id: str) -> dict[str, Any]:
|
|
129
|
+
return await self._post(f"/tasks/{ticket_id}/cancel", json=None)
|
|
130
|
+
|
|
131
|
+
async def submit_feedback(
|
|
132
|
+
self,
|
|
133
|
+
ticket_id: str,
|
|
134
|
+
*,
|
|
135
|
+
rating: str,
|
|
136
|
+
comment: Optional[str] = None,
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
"""Leave feedback on a finished task. `rating` is "good"/"bad";
|
|
139
|
+
`comment` is an optional note. The API 409s unless the ticket is
|
|
140
|
+
completed or failed."""
|
|
141
|
+
body: dict[str, Any] = {"rating": rating}
|
|
142
|
+
if comment is not None:
|
|
143
|
+
body["comment"] = comment
|
|
144
|
+
return await self._post(f"/tasks/{ticket_id}/feedback", json=body)
|
|
145
|
+
|
|
146
|
+
# --- Skills -------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
async def list_skills(
|
|
149
|
+
self, *, source_format: Optional[str] = None
|
|
150
|
+
) -> list[dict[str, Any]]:
|
|
151
|
+
params: dict[str, Any] = {}
|
|
152
|
+
if source_format:
|
|
153
|
+
params["source_format"] = source_format
|
|
154
|
+
result = await self._get("/skills", params=params)
|
|
155
|
+
# /skills returns either a bare list or {"items": [...]} —
|
|
156
|
+
# accept either to stay forward-compatible.
|
|
157
|
+
if isinstance(result, dict) and "items" in result:
|
|
158
|
+
return result["items"]
|
|
159
|
+
return result # type: ignore[return-value]
|
|
160
|
+
|
|
161
|
+
# --- Actions (P10) ------------------------------------------------
|
|
162
|
+
|
|
163
|
+
async def list_actions(
|
|
164
|
+
self,
|
|
165
|
+
*,
|
|
166
|
+
provider: Optional[str] = None,
|
|
167
|
+
category: Optional[str] = None,
|
|
168
|
+
) -> dict[str, Any]:
|
|
169
|
+
"""GET /v1/actions — returns the full catalog response shape
|
|
170
|
+
({count, actions, providers, categories}). The MCP server
|
|
171
|
+
decides what subset to surface to the agent host."""
|
|
172
|
+
params: dict[str, Any] = {}
|
|
173
|
+
if provider:
|
|
174
|
+
params["provider"] = provider
|
|
175
|
+
if category:
|
|
176
|
+
params["category"] = category
|
|
177
|
+
return await self._get("/v1/actions", params=params)
|
|
178
|
+
|
|
179
|
+
async def get_action(self, action_id: str) -> dict[str, Any]:
|
|
180
|
+
"""GET /v1/actions/{id} — full ActionDefinition with the
|
|
181
|
+
input/output schemas."""
|
|
182
|
+
return await self._get(f"/v1/actions/{action_id}")
|
|
183
|
+
|
|
184
|
+
async def invoke_action(
|
|
185
|
+
self,
|
|
186
|
+
action_id: str,
|
|
187
|
+
*,
|
|
188
|
+
inputs: dict[str, Any],
|
|
189
|
+
executor: Optional[str] = None,
|
|
190
|
+
) -> dict[str, Any]:
|
|
191
|
+
"""POST /v1/actions/{id}.
|
|
192
|
+
|
|
193
|
+
Two response shapes depending on the action's executor:
|
|
194
|
+
* API actions (gmail, slack, github, ...): run sync. Outcome
|
|
195
|
+
is one of 'succeeded' | 'blocked' | 'failed' with the
|
|
196
|
+
output inline.
|
|
197
|
+
* Browser actions (resy, partiful, usps, ...): return 202
|
|
198
|
+
with outcome='queued' + payload.ticket_id + payload.poll_path.
|
|
199
|
+
Caller polls get_action_ticket(ticket_id) until is_terminal.
|
|
200
|
+
|
|
201
|
+
Same shape either way (ActionInvokeResponse). Caller branches
|
|
202
|
+
on outcome to decide whether to poll.
|
|
203
|
+
"""
|
|
204
|
+
body: dict[str, Any] = {"inputs": inputs}
|
|
205
|
+
if executor:
|
|
206
|
+
body["executor"] = executor
|
|
207
|
+
return await self._post(f"/v1/actions/{action_id}", json=body)
|
|
208
|
+
|
|
209
|
+
async def get_action_ticket(self, ticket_id: str) -> dict[str, Any]:
|
|
210
|
+
"""GET /v1/actions/tickets/{id} — poll an async action ticket.
|
|
211
|
+
|
|
212
|
+
Returns ActionTicketResponse: state, outcome, output, error,
|
|
213
|
+
reason, blocked_reason, is_terminal, creds_prompt. Poll every
|
|
214
|
+
3-5s until is_terminal=True. When creds_prompt is set the
|
|
215
|
+
ticket is waiting for the user to submit credentials via
|
|
216
|
+
provide_credentials.
|
|
217
|
+
"""
|
|
218
|
+
return await self._get(f"/v1/actions/tickets/{ticket_id}")
|
|
219
|
+
|
|
220
|
+
async def provide_credentials(
|
|
221
|
+
self,
|
|
222
|
+
ticket_id: str,
|
|
223
|
+
*,
|
|
224
|
+
fields: dict[str, str],
|
|
225
|
+
remember_for_session: bool = False,
|
|
226
|
+
) -> dict[str, Any]:
|
|
227
|
+
"""POST /v1/actions/tickets/{id}/credentials — submit creds
|
|
228
|
+
for a ticket blocked with creds_required.
|
|
229
|
+
|
|
230
|
+
Credentials are KMS-encrypted in transit + at rest in Redis
|
|
231
|
+
with a 15-minute TTL (single-use GETDEL by the worker). Never
|
|
232
|
+
written to Postgres or logs.
|
|
233
|
+
"""
|
|
234
|
+
body: dict[str, Any] = {"fields": fields}
|
|
235
|
+
if remember_for_session:
|
|
236
|
+
body["remember_for_session"] = True
|
|
237
|
+
return await self._post(
|
|
238
|
+
f"/v1/actions/tickets/{ticket_id}/credentials", json=body
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# --- Jobs (P6) ----------------------------------------------------
|
|
242
|
+
|
|
243
|
+
async def create_job(
|
|
244
|
+
self,
|
|
245
|
+
*,
|
|
246
|
+
goal: str,
|
|
247
|
+
budget_usd: Optional[float] = None,
|
|
248
|
+
deadline: Optional[str] = None,
|
|
249
|
+
webhook_url: Optional[str] = None,
|
|
250
|
+
) -> dict[str, Any]:
|
|
251
|
+
"""POST /v1/jobs — kick off a multi-step planner-driven job.
|
|
252
|
+
|
|
253
|
+
`deadline` is ISO 8601 (e.g. '2026-05-15T19:00:00-07:00').
|
|
254
|
+
Returns the job in 'planning' state; caller polls get_job.
|
|
255
|
+
"""
|
|
256
|
+
body: dict[str, Any] = {"goal": goal}
|
|
257
|
+
if budget_usd is not None:
|
|
258
|
+
body["budget_usd"] = budget_usd
|
|
259
|
+
if deadline is not None:
|
|
260
|
+
body["deadline"] = deadline
|
|
261
|
+
if webhook_url is not None:
|
|
262
|
+
body["webhook_url"] = webhook_url
|
|
263
|
+
return await self._post("/v1/jobs", json=body)
|
|
264
|
+
|
|
265
|
+
async def get_job(self, job_id: str) -> dict[str, Any]:
|
|
266
|
+
"""GET /v1/jobs/{id} — returns the full Job + items array."""
|
|
267
|
+
return await self._get(f"/v1/jobs/{job_id}")
|
|
268
|
+
|
|
269
|
+
async def cancel_job(self, job_id: str) -> dict[str, Any]:
|
|
270
|
+
return await self._post(f"/v1/jobs/{job_id}/cancel", json=None)
|
|
271
|
+
|
|
272
|
+
async def resume_job(self, job_id: str) -> dict[str, Any]:
|
|
273
|
+
return await self._post(f"/v1/jobs/{job_id}/resume", json=None)
|
|
274
|
+
|
|
275
|
+
# --- Internals ----------------------------------------------------
|
|
276
|
+
|
|
277
|
+
async def _get(
|
|
278
|
+
self, path: str, *, params: Optional[dict[str, Any]] = None
|
|
279
|
+
) -> Any:
|
|
280
|
+
resp = await self._client.get(path, params=params)
|
|
281
|
+
return _parse(resp)
|
|
282
|
+
|
|
283
|
+
async def _post(
|
|
284
|
+
self,
|
|
285
|
+
path: str,
|
|
286
|
+
*,
|
|
287
|
+
json: Optional[dict[str, Any]],
|
|
288
|
+
headers: Optional[dict[str, str]] = None,
|
|
289
|
+
) -> dict[str, Any]:
|
|
290
|
+
resp = await self._client.post(path, json=json, headers=headers)
|
|
291
|
+
return _parse(resp)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _parse(resp: httpx.Response) -> Any:
|
|
295
|
+
if resp.is_success:
|
|
296
|
+
if not resp.content:
|
|
297
|
+
return {}
|
|
298
|
+
return resp.json()
|
|
299
|
+
detail: str
|
|
300
|
+
try:
|
|
301
|
+
body = resp.json()
|
|
302
|
+
detail = body.get("detail") if isinstance(body, dict) else str(body)
|
|
303
|
+
if not isinstance(detail, str):
|
|
304
|
+
detail = str(detail)
|
|
305
|
+
except Exception:
|
|
306
|
+
detail = resp.text or resp.reason_phrase
|
|
307
|
+
# SEC-MCP-011: for non-validation 5xx responses, collapse the
|
|
308
|
+
# server-emitted detail to a generic message. Validation 4xx is
|
|
309
|
+
# preserved -- the user / agent needs the specific reason to fix
|
|
310
|
+
# the call. A future verbose 500 path that leaks DB ids / file
|
|
311
|
+
# paths / cross-tenant data no longer surfaces through the SDK
|
|
312
|
+
# exception message.
|
|
313
|
+
if 500 <= resp.status_code < 600:
|
|
314
|
+
detail = "internal error -- check the ActionLayer status page"
|
|
315
|
+
raise ApiError(resp.status_code, detail)
|
|
316
|
+
|
|
317
|
+
|