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,1112 @@
|
|
|
1
|
+
"""ActionLayer MCP server — stdio JSON-RPC, 5 tools.
|
|
2
|
+
|
|
3
|
+
Tools (locked surface — agents will see these names):
|
|
4
|
+
|
|
5
|
+
actionlayer_start_task create a ticket
|
|
6
|
+
actionlayer_get_task read state + recent transcript
|
|
7
|
+
actionlayer_reply resume a blocked_on_user ticket
|
|
8
|
+
actionlayer_cancel cancel a non-terminal ticket
|
|
9
|
+
actionlayer_list_skills list user's skills
|
|
10
|
+
|
|
11
|
+
When a ticket is blocked_on_user, the latest event payload contains the
|
|
12
|
+
prompt ActionLayer is waiting on. We surface that prompt in
|
|
13
|
+
actionlayer_get_task's response with `next_action` set to the prompt
|
|
14
|
+
string. The agent host then asks the end user, who replies via
|
|
15
|
+
actionlayer_reply. If `sensitive=true` is on the prompt, the tool's
|
|
16
|
+
description tells the agent to ask the user directly and never echo
|
|
17
|
+
the value to other tools — the value goes straight from the user into
|
|
18
|
+
actionlayer_reply with sensitive=true.
|
|
19
|
+
|
|
20
|
+
State values surfaced here are the EXTERNAL taxonomy:
|
|
21
|
+
|
|
22
|
+
pending -> blocked_on_user | completed | failed | cancelled
|
|
23
|
+
|
|
24
|
+
`pending` is the catch-all for "we're working on it" — internal
|
|
25
|
+
phases (queued, planning, executing, internal handoff) all collapse
|
|
26
|
+
into it so the agent has nothing to model beyond "keep polling."
|
|
27
|
+
`completed` and `failed` carry a free-text `reason`; `pending`,
|
|
28
|
+
`blocked_on_user`, and `cancelled` do not.
|
|
29
|
+
|
|
30
|
+
The API does the external-name translation server-side before the
|
|
31
|
+
tool ever sees a state string, so this module renders whatever it
|
|
32
|
+
gets without further mapping.
|
|
33
|
+
|
|
34
|
+
The server is intentionally small: ~250 lines. Anything more belongs in
|
|
35
|
+
client.py or back in the API.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import asyncio
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import sys
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Any, Optional
|
|
47
|
+
|
|
48
|
+
# Force-load the lazy `concurrent.futures.thread` submodule at startup.
|
|
49
|
+
# Python 3.9+ defers it until the first ThreadPoolExecutor touch (which
|
|
50
|
+
# anyio's asyncio backend triggers under httpx), so a broken / stripped
|
|
51
|
+
# stdlib install surfaces mid-tool-call with a confusing
|
|
52
|
+
# "No module named 'concurrent.futures.thread'" instead of at boot.
|
|
53
|
+
import concurrent.futures.thread # noqa: F401
|
|
54
|
+
|
|
55
|
+
from fastmcp import FastMCP
|
|
56
|
+
|
|
57
|
+
from actionlayer_mcp.client import ActionLayerClient, ApiError
|
|
58
|
+
|
|
59
|
+
mcp = FastMCP(
|
|
60
|
+
name="actionlayer",
|
|
61
|
+
instructions=(
|
|
62
|
+
"ActionLayer is the execution layer that FINISHES THE JOB on "
|
|
63
|
+
"ANY website. Most agents quit at the hard part — CAPTCHA, "
|
|
64
|
+
"novel UI, a login wall, a weird checkout, a registrar form, "
|
|
65
|
+
"a hosting console. ActionLayer doesn't. It's an AI agent "
|
|
66
|
+
"that operates a real browser end-to-end, reading the page "
|
|
67
|
+
"and adapting until the job is done — from your perspective "
|
|
68
|
+
"the ticket stays `pending` until it's either `completed` "
|
|
69
|
+
"or asks the user something. "
|
|
70
|
+
"The core value proposition: an agent that doesn't have to "
|
|
71
|
+
"quit, on sites that have never been mapped in advance.\n\n"
|
|
72
|
+
"USE ACTIONLAYER FOR: anything the user wants DONE on a live "
|
|
73
|
+
"website — book, order, sign up, register, deploy, fill a "
|
|
74
|
+
"form, log in, click through a flow, extract data, automate "
|
|
75
|
+
"any URL. Especially valuable when no public API exists. "
|
|
76
|
+
"DON'T USE IT FOR: text/code generation, analysis, "
|
|
77
|
+
"summarization — those are yours.\n\n"
|
|
78
|
+
"OPEN-ENDED BY DEFAULT. ActionLayer is NOT a fixed catalog "
|
|
79
|
+
"of a few verticals. Two entry points cover anything "
|
|
80
|
+
"reachable from a browser, including sites you've never "
|
|
81
|
+
"seen:\n"
|
|
82
|
+
" • actionlayer_start_task(goal) — natural-language goal, "
|
|
83
|
+
"the agent plans and executes across one or multiple sites.\n"
|
|
84
|
+
" • actionlayer_invoke_action with "
|
|
85
|
+
"action_id='direct.browser_action' + {url, instruction} — "
|
|
86
|
+
"point the agent at a specific URL with a specific "
|
|
87
|
+
"instruction. Same execution model as start_task.\n"
|
|
88
|
+
"If you catch yourself thinking \"we don't have a typed "
|
|
89
|
+
"action for this site\" — that's not a blocker, that's "
|
|
90
|
+
"exactly what these two are for.\n\n"
|
|
91
|
+
"TYPED SHORTCUTS (when you already know the id). For a handful "
|
|
92
|
+
"of high-frequency flows there are typed action ids (e.g. "
|
|
93
|
+
"'resy.book_table') you can pass to actionlayer_invoke_action "
|
|
94
|
+
"directly when the user's intent matches one exactly. They "
|
|
95
|
+
"are an internal optimization, not the menu — there's no "
|
|
96
|
+
"listing of them, by design. If you don't already know an "
|
|
97
|
+
"id, default to actionlayer_start_task or "
|
|
98
|
+
"actionlayer_invoke_action(action_id='direct.browser_action'"
|
|
99
|
+
").\n\n"
|
|
100
|
+
"NOT SURE WHAT FITS? Call actionlayer_list_actions for a "
|
|
101
|
+
"prose capability sketch of what ActionLayer can do, with "
|
|
102
|
+
"how-to examples for both entry points. It is not a catalog "
|
|
103
|
+
"and does not enumerate typed ids — it's there to help you "
|
|
104
|
+
"decide whether to invoke and which entry point to "
|
|
105
|
+
"choose.\n\n"
|
|
106
|
+
"TASK SPECIFICITY. The goal passed to actionlayer_start_task "
|
|
107
|
+
"must be SPECIFIC. Vague goals (\"book me dinner\", \"order "
|
|
108
|
+
"something\") block on the user and waste planning budget. "
|
|
109
|
+
"Gather specifics from the user FIRST, then call start_task. "
|
|
110
|
+
"For multi-step plans with output piping between steps (e.g. "
|
|
111
|
+
"'find a slot then send the invite'), use "
|
|
112
|
+
"actionlayer_create_job.\n\n"
|
|
113
|
+
"PENDING STATE. While state='pending' the ticket is "
|
|
114
|
+
"advancing on our end. You DON'T need to do anything — keep "
|
|
115
|
+
"polling, the ticket will keep moving. There is no "
|
|
116
|
+
"sub-status to interpret; `pending` covers everything from "
|
|
117
|
+
"the moment we accept the ticket until it terminates or "
|
|
118
|
+
"asks the user.\n\n"
|
|
119
|
+
"BLOCKED-ON-USER PATTERN. ActionLayer needs something only "
|
|
120
|
+
"the user can provide (date, address, choice, 2FA). The "
|
|
121
|
+
"latest event has a prompt; ask the user, then call "
|
|
122
|
+
"actionlayer_reply with the answer. If the prompt is marked "
|
|
123
|
+
"sensitive (2FA, password), DO NOT echo the value to any "
|
|
124
|
+
"other tool — pass it directly to actionlayer_reply with "
|
|
125
|
+
"sensitive=true."
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Global client — opened on first use, closed at process exit.
|
|
131
|
+
_client: Optional[ActionLayerClient] = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _get_client() -> ActionLayerClient:
|
|
135
|
+
global _client
|
|
136
|
+
if _client is None:
|
|
137
|
+
_client = ActionLayerClient()
|
|
138
|
+
return _client
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _summarize_events(events: list[dict[str, Any]], limit: int = 5) -> list[dict[str, Any]]:
|
|
142
|
+
"""Keep responses small — agents don't need the full transcript on every poll."""
|
|
143
|
+
if not events:
|
|
144
|
+
return []
|
|
145
|
+
return [
|
|
146
|
+
{
|
|
147
|
+
"type": e.get("type"),
|
|
148
|
+
"to_state": e.get("to_state"),
|
|
149
|
+
"created_at": e.get("created_at"),
|
|
150
|
+
"payload": e.get("payload"),
|
|
151
|
+
}
|
|
152
|
+
for e in events[-limit:]
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _next_action(ticket: dict[str, Any]) -> Optional[dict[str, Any]]:
|
|
157
|
+
"""If the ticket is waiting on the user, surface what they need.
|
|
158
|
+
|
|
159
|
+
Returns None when the ticket is not blocked. Two shapes,
|
|
160
|
+
distinguished by `kind` so the agent picks the right
|
|
161
|
+
actionlayer_reply path:
|
|
162
|
+
|
|
163
|
+
"kind": "info_request" — ActionLayer paused to collect info
|
|
164
|
+
(and optionally payment) from the
|
|
165
|
+
user. For info fields, the agent
|
|
166
|
+
calls actionlayer_reply with
|
|
167
|
+
`field_values` whose keys match the
|
|
168
|
+
union of `requested_fields` and every
|
|
169
|
+
question's `key`. Payment is NEVER
|
|
170
|
+
approved in chat — the user approves
|
|
171
|
+
in the Link app on their phone.
|
|
172
|
+
{
|
|
173
|
+
"kind": "info_request",
|
|
174
|
+
"prompt": "<ActionLayer's note, if any>",
|
|
175
|
+
"requested_fields": ["origin_address", "phone_number"],
|
|
176
|
+
# ActionLayer's follow-up questions. Render `label`
|
|
177
|
+
# verbatim to the user; capture their answer under `key`.
|
|
178
|
+
"questions": [
|
|
179
|
+
{"key": "q_0", "label": "First or business class?"},
|
|
180
|
+
],
|
|
181
|
+
"amount_usd": 25.0 | null,
|
|
182
|
+
# Merchant the user would be paying, as shown on their
|
|
183
|
+
# Link approval screen.
|
|
184
|
+
"merchant_name": "Stripe Press" | null,
|
|
185
|
+
# Payment-ask state. Drives what the agent should tell the
|
|
186
|
+
# user. null on info-only asks.
|
|
187
|
+
# "wallet_required" — no Link wallet connected;
|
|
188
|
+
# ask the user to connect one on the dashboard's
|
|
189
|
+
# Connections page (they'll need the Link app on
|
|
190
|
+
# their phone), then the ask proceeds automatically.
|
|
191
|
+
# "pending_info" — answer the info fields
|
|
192
|
+
# first; the payment approval request is sent to
|
|
193
|
+
# the user's Link app right after the reply lands.
|
|
194
|
+
# "pending_wallet_approval" — an approval request is in
|
|
195
|
+
# the user's Link app NOW (10-minute window). Tell
|
|
196
|
+
# them to open Link and approve $X there. Polling
|
|
197
|
+
# actionlayer_get_task discovers the approval.
|
|
198
|
+
# "approval_expired" — the window lapsed. Reply
|
|
199
|
+
# with payment_approved=true to send a fresh
|
|
200
|
+
# approval request to their Link app.
|
|
201
|
+
# "rejected" — the user declined.
|
|
202
|
+
"payment_status": "wallet_required" | "pending_info"
|
|
203
|
+
| "pending_wallet_approval"
|
|
204
|
+
| "approval_expired" | "rejected" | null,
|
|
205
|
+
# User-safe explanation when the payment hit a snag (e.g.
|
|
206
|
+
# a wallet spending limit). Relay verbatim.
|
|
207
|
+
"payment_error": "<string>" | null,
|
|
208
|
+
"expires_at": "<iso8601>",
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
"kind": "block_on_user" — ActionLayer needs one piece of info
|
|
212
|
+
via the standard reply path.
|
|
213
|
+
Agent calls actionlayer_reply with
|
|
214
|
+
field + value (+ sensitive=true for
|
|
215
|
+
secrets).
|
|
216
|
+
{
|
|
217
|
+
"kind": "block_on_user",
|
|
218
|
+
"prompt": "Two-factor code from your phone?",
|
|
219
|
+
"field": "sms_code", (when present)
|
|
220
|
+
"sensitive": true|false,
|
|
221
|
+
"reason": "creds_required" | other, (when present)
|
|
222
|
+
"site": "amazon.com", (when reason="creds_required")
|
|
223
|
+
"fields": ["email", "password"], (when reason="creds_required")
|
|
224
|
+
"credential_intake_url": "https://...?t=…", (when reason="creds_required";
|
|
225
|
+
render as a clickable link
|
|
226
|
+
so the user can store their
|
|
227
|
+
credentials without typing
|
|
228
|
+
them in chat.)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
Drives the agent's behavior — the description on actionlayer_reply
|
|
232
|
+
tells the agent to read .kind and pick the matching arg shape.
|
|
233
|
+
"""
|
|
234
|
+
if ticket.get("state") != "blocked_on_user":
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
# Info-required path wins when the ticket carries an
|
|
238
|
+
# info_request. The reply route dispatches on
|
|
239
|
+
# `ticket.info_request IS NOT NULL` regardless of which event
|
|
240
|
+
# type fired last, so we mirror that here.
|
|
241
|
+
info_req = ticket.get("info_request") or None
|
|
242
|
+
if info_req:
|
|
243
|
+
questions_raw = info_req.get("questions") or []
|
|
244
|
+
questions = [
|
|
245
|
+
{"key": q.get("key"), "label": q.get("label")}
|
|
246
|
+
for q in questions_raw
|
|
247
|
+
if isinstance(q, dict) and q.get("key") and q.get("label")
|
|
248
|
+
]
|
|
249
|
+
return {
|
|
250
|
+
"kind": "info_request",
|
|
251
|
+
"prompt": info_req.get("note") or "ActionLayer needs more information to continue.",
|
|
252
|
+
"requested_fields": list(info_req.get("requested_fields") or []),
|
|
253
|
+
"questions": questions,
|
|
254
|
+
"amount_usd": info_req.get("amount_usd"),
|
|
255
|
+
"merchant_name": info_req.get("merchant_name"),
|
|
256
|
+
"payment_status": info_req.get("payment_status"),
|
|
257
|
+
"payment_error": info_req.get("payment_error"),
|
|
258
|
+
"expires_at": info_req.get("expires_at"),
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
events = ticket.get("events") or []
|
|
262
|
+
block_events = [e for e in events if e.get("type") == "block_on_user"]
|
|
263
|
+
if not block_events:
|
|
264
|
+
return {"kind": "block_on_user", "prompt": "Waiting for user input.", "sensitive": False}
|
|
265
|
+
payload = block_events[-1].get("payload") or {}
|
|
266
|
+
out: dict[str, Any] = {
|
|
267
|
+
"kind": "block_on_user",
|
|
268
|
+
"prompt": payload.get("prompt", "Waiting for user input."),
|
|
269
|
+
"field": payload.get("field"),
|
|
270
|
+
"sensitive": bool(payload.get("sensitive", False)),
|
|
271
|
+
}
|
|
272
|
+
# Surface the creds_required signals when present. The OA renders
|
|
273
|
+
# `credential_intake_url` as a clickable link so the user can store
|
|
274
|
+
# their credentials via the dedicated web form rather than pasting
|
|
275
|
+
# them through chat. `reason`, `site`, and `fields` give the OA
|
|
276
|
+
# enough context to write a helpful prompt around the link.
|
|
277
|
+
for key in ("reason", "site", "fields", "credential_intake_url"):
|
|
278
|
+
value = payload.get(key)
|
|
279
|
+
if value is not None:
|
|
280
|
+
out[key] = value
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# --- Tools ------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@mcp.tool()
|
|
288
|
+
async def actionlayer_start_task(
|
|
289
|
+
goal: str,
|
|
290
|
+
max_budget_usd: Optional[float] = None,
|
|
291
|
+
webhook_url: Optional[str] = None,
|
|
292
|
+
idempotency_key: Optional[str] = None,
|
|
293
|
+
target_url: Optional[str] = None,
|
|
294
|
+
use_my_credentials: Optional[bool] = None,
|
|
295
|
+
) -> dict[str, Any]:
|
|
296
|
+
"""Start a new ActionLayer task.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
goal: A natural-language description of what to do. Examples:
|
|
300
|
+
"Send Alex at alex@x.com an email to set up a 30-min call"
|
|
301
|
+
"Book a table for 2 at Liholiho, Friday 7pm"
|
|
302
|
+
"Hire someone to assemble an IKEA Malm dresser this Saturday"
|
|
303
|
+
max_budget_usd: Optional spend cap. The flow refuses to commit
|
|
304
|
+
money beyond this.
|
|
305
|
+
webhook_url: Optional URL ActionLayer POSTs ticket events to.
|
|
306
|
+
idempotency_key: Optional caller-supplied retry key. If you call
|
|
307
|
+
this tool twice with the same key within 24h, you get the
|
|
308
|
+
same ticket back — safe across timeouts and retries.
|
|
309
|
+
target_url: Optional. The specific https:// page the task should
|
|
310
|
+
run on. If the user's request clearly implies a website you
|
|
311
|
+
know (e.g. "make a meme" -> imgflip.com, "order coffee" ->
|
|
312
|
+
doordash.com, a tracking number -> usps.com), pass that
|
|
313
|
+
site's URL here — you have the conversation context, so you
|
|
314
|
+
resolve the destination better than ActionLayer can. When
|
|
315
|
+
omitted, ActionLayer figures out the destination itself
|
|
316
|
+
(slower if it has to fall back to a web search).
|
|
317
|
+
use_my_credentials: Optional. Set True when the user explicitly
|
|
318
|
+
wants the task to run on their own personal account at the
|
|
319
|
+
target site (e.g. "send the email from MY Gmail"). Set False
|
|
320
|
+
to force an ActionLayer-owned account (the user's underlying
|
|
321
|
+
identity is irrelevant). Leave unset to let the action's
|
|
322
|
+
default policy decide. If True and the user has no saved
|
|
323
|
+
credentials for the target site, the response from
|
|
324
|
+
`actionlayer_get_task` will surface a `credential_intake_url`
|
|
325
|
+
in `next_action` — render it as a clickable link so the user
|
|
326
|
+
can store their credentials safely.
|
|
327
|
+
|
|
328
|
+
Returns the ticket as {ticket_id, state, ...}. State is initially
|
|
329
|
+
"pending"; poll actionlayer_get_task to watch it progress.
|
|
330
|
+
"""
|
|
331
|
+
client = _get_client()
|
|
332
|
+
credentials_source: Optional[str] = None
|
|
333
|
+
if use_my_credentials is True:
|
|
334
|
+
credentials_source = "user"
|
|
335
|
+
elif use_my_credentials is False:
|
|
336
|
+
credentials_source = "service"
|
|
337
|
+
try:
|
|
338
|
+
ticket = await client.start_task(
|
|
339
|
+
goal=goal,
|
|
340
|
+
max_budget_usd=max_budget_usd,
|
|
341
|
+
webhook_url=webhook_url,
|
|
342
|
+
idempotency_key=idempotency_key,
|
|
343
|
+
target_url=target_url,
|
|
344
|
+
credentials_source=credentials_source,
|
|
345
|
+
)
|
|
346
|
+
except ApiError as e:
|
|
347
|
+
return {"error": str(e), "status": e.status}
|
|
348
|
+
return {
|
|
349
|
+
"ticket_id": ticket.get("id"),
|
|
350
|
+
"state": ticket.get("state"),
|
|
351
|
+
"goal": ticket.get("goal"),
|
|
352
|
+
"created_at": ticket.get("created_at"),
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@mcp.tool()
|
|
357
|
+
async def actionlayer_get_task(ticket_id: str) -> dict[str, Any]:
|
|
358
|
+
"""Read the current state of a ticket.
|
|
359
|
+
|
|
360
|
+
Poll this until `state` is one of: completed, failed, cancelled, or
|
|
361
|
+
blocked_on_user. While `state` is `pending` ActionLayer is working
|
|
362
|
+
on it — keep polling, you have nothing to do. If `blocked_on_user`,
|
|
363
|
+
the response includes a `next_action` object with the prompt the
|
|
364
|
+
human needs to answer.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
ticket_id, state, goal, flow, result (when completed),
|
|
368
|
+
error (when failed), reason (free-text summary on completed
|
|
369
|
+
/ failed; null otherwise), recent_events (last 5),
|
|
370
|
+
next_action (when blocked_on_user).
|
|
371
|
+
"""
|
|
372
|
+
client = _get_client()
|
|
373
|
+
try:
|
|
374
|
+
ticket = await client.get_task(ticket_id)
|
|
375
|
+
except ApiError as e:
|
|
376
|
+
return {"error": str(e), "status": e.status, "ticket_id": ticket_id}
|
|
377
|
+
return {
|
|
378
|
+
"ticket_id": ticket.get("id"),
|
|
379
|
+
"state": ticket.get("state"),
|
|
380
|
+
"goal": ticket.get("goal"),
|
|
381
|
+
"flow": ticket.get("flow"),
|
|
382
|
+
"result": ticket.get("result"),
|
|
383
|
+
"error": ticket.get("error"),
|
|
384
|
+
"reason": ticket.get("reason"),
|
|
385
|
+
"created_at": ticket.get("created_at"),
|
|
386
|
+
"completed_at": ticket.get("completed_at"),
|
|
387
|
+
"recent_events": _summarize_events(ticket.get("events") or []),
|
|
388
|
+
"next_action": _next_action(ticket),
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@mcp.tool()
|
|
393
|
+
async def actionlayer_reply(
|
|
394
|
+
ticket_id: str,
|
|
395
|
+
message: Optional[str] = None,
|
|
396
|
+
field: Optional[str] = None,
|
|
397
|
+
value: Optional[str] = None,
|
|
398
|
+
sensitive: bool = False,
|
|
399
|
+
field_values: Optional[dict[str, str]] = None,
|
|
400
|
+
payment_approved: Optional[bool] = None,
|
|
401
|
+
) -> dict[str, Any]:
|
|
402
|
+
"""Resume a ticket that's waiting on the user.
|
|
403
|
+
|
|
404
|
+
Use this only when actionlayer_get_task returned state
|
|
405
|
+
"blocked_on_user". The `next_action` object on that response tells
|
|
406
|
+
you which shape to send.
|
|
407
|
+
|
|
408
|
+
TWO SHAPES, picked from next_action.kind:
|
|
409
|
+
|
|
410
|
+
1. STANDARD REPLY (next_action.kind == "block_on_user"): pass
|
|
411
|
+
`field` + `value` (and `sensitive=true` for secrets). This is
|
|
412
|
+
the common case — ActionLayer needs one piece of info
|
|
413
|
+
(date, address, 2FA code), ticket then goes back to EXECUTING.
|
|
414
|
+
|
|
415
|
+
2. INFO-REQUIRED REPLY (next_action.kind == "info_request"): pass
|
|
416
|
+
`field_values` as a dict {requested_field: user_value} whose
|
|
417
|
+
keys EXACTLY MATCH next_action.requested_fields ∪
|
|
418
|
+
{q.key for q in next_action.questions}.
|
|
419
|
+
|
|
420
|
+
PAYMENTS ARE APPROVED IN THE LINK APP, NEVER IN CHAT. When
|
|
421
|
+
next_action.amount_usd is set, branch on
|
|
422
|
+
next_action.payment_status:
|
|
423
|
+
- "wallet_required": the user has no Link wallet connected.
|
|
424
|
+
Tell them to connect one at the ActionLayer dashboard's
|
|
425
|
+
Connections page (they'll need the Link app on their
|
|
426
|
+
phone); DO NOT call actionlayer_reply yet. Once connected,
|
|
427
|
+
the ask proceeds automatically.
|
|
428
|
+
- "pending_info": collect the requested info and call
|
|
429
|
+
actionlayer_reply with `field_values`. The payment
|
|
430
|
+
approval request is sent to the user's Link app right
|
|
431
|
+
after — tell them to expect it there.
|
|
432
|
+
- "pending_wallet_approval": an approval request for $X is
|
|
433
|
+
waiting in the user's Link app RIGHT NOW (10-minute
|
|
434
|
+
window). Tell them to open Link and approve it there;
|
|
435
|
+
poll actionlayer_get_task to see it land.
|
|
436
|
+
- "approval_expired": the window lapsed. Call
|
|
437
|
+
actionlayer_reply with payment_approved=true to send a
|
|
438
|
+
fresh approval request to their Link app.
|
|
439
|
+
The user can decline at any point: payment_approved=false
|
|
440
|
+
fails the task (reason payment_rejected). No card data is
|
|
441
|
+
ever fetched or sent through this call.
|
|
442
|
+
|
|
443
|
+
SENSITIVE VALUES (passwords, 2FA codes): set `sensitive=true`.
|
|
444
|
+
Pass the value the user typed directly — DO NOT echo it into
|
|
445
|
+
other tool calls, agent thoughts, or messages. The API encrypts
|
|
446
|
+
it at rest, redacts it in logs, and purges it when the ticket
|
|
447
|
+
terminates.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
ticket_id: The ticket waiting on the user.
|
|
451
|
+
message: Optional free-form note (non-secret). Lands on the
|
|
452
|
+
transcript as-is.
|
|
453
|
+
field: Standard reply — machine-readable key from
|
|
454
|
+
next_action.field.
|
|
455
|
+
value: Standard reply — the user's answer.
|
|
456
|
+
sensitive: Standard reply — True if value is a secret.
|
|
457
|
+
field_values: Info-required reply — {field: value} dict
|
|
458
|
+
matching next_action.requested_fields ∪ question keys
|
|
459
|
+
exactly.
|
|
460
|
+
payment_approved: Payment-ask control. False declines the
|
|
461
|
+
charge (ticket FAILS with reason payment_rejected). True
|
|
462
|
+
re-sends the approval push when payment_status is
|
|
463
|
+
"approval_expired". The approval itself always happens
|
|
464
|
+
in the user's Link app.
|
|
465
|
+
"""
|
|
466
|
+
# Build the nested info payload when either flat info-required
|
|
467
|
+
# arg is present. The route 422s if the ticket carries an
|
|
468
|
+
# info_request but `info` is missing on the reply.
|
|
469
|
+
info: Optional[dict[str, Any]] = None
|
|
470
|
+
if field_values is not None or payment_approved is not None:
|
|
471
|
+
info = {}
|
|
472
|
+
if field_values is not None:
|
|
473
|
+
info["field_values"] = field_values
|
|
474
|
+
if payment_approved is not None:
|
|
475
|
+
info["payment_approved"] = payment_approved
|
|
476
|
+
|
|
477
|
+
client = _get_client()
|
|
478
|
+
try:
|
|
479
|
+
ticket = await client.reply_task(
|
|
480
|
+
ticket_id,
|
|
481
|
+
message=message,
|
|
482
|
+
field=field,
|
|
483
|
+
value=value,
|
|
484
|
+
sensitive=sensitive,
|
|
485
|
+
info=info,
|
|
486
|
+
)
|
|
487
|
+
except ApiError as e:
|
|
488
|
+
return {"error": str(e), "status": e.status, "ticket_id": ticket_id}
|
|
489
|
+
return {
|
|
490
|
+
"ticket_id": ticket.get("id"),
|
|
491
|
+
"state": ticket.get("state"),
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@mcp.tool()
|
|
496
|
+
async def actionlayer_cancel(ticket_id: str) -> dict[str, Any]:
|
|
497
|
+
"""Cancel a non-terminal ticket.
|
|
498
|
+
|
|
499
|
+
Idempotent on terminal states — calling cancel on a completed/
|
|
500
|
+
failed/already-cancelled ticket returns the ticket as-is.
|
|
501
|
+
"""
|
|
502
|
+
client = _get_client()
|
|
503
|
+
try:
|
|
504
|
+
ticket = await client.cancel_task(ticket_id)
|
|
505
|
+
except ApiError as e:
|
|
506
|
+
return {"error": str(e), "status": e.status, "ticket_id": ticket_id}
|
|
507
|
+
return {
|
|
508
|
+
"ticket_id": ticket.get("id"),
|
|
509
|
+
"state": ticket.get("state"),
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@mcp.tool()
|
|
514
|
+
async def actionlayer_submit_feedback(
|
|
515
|
+
ticket_id: str,
|
|
516
|
+
rating: str,
|
|
517
|
+
comment: Optional[str] = None,
|
|
518
|
+
) -> dict[str, Any]:
|
|
519
|
+
"""Leave feedback on a finished task.
|
|
520
|
+
|
|
521
|
+
Use after actionlayer_get_task shows state "completed" or "failed"
|
|
522
|
+
(the API rejects feedback on any other state). `rating` is "good"
|
|
523
|
+
or "bad"; `comment` is an optional free-text note. One feedback per
|
|
524
|
+
task — calling again overwrites the previous rating/note.
|
|
525
|
+
"""
|
|
526
|
+
client = _get_client()
|
|
527
|
+
try:
|
|
528
|
+
result = await client.submit_feedback(
|
|
529
|
+
ticket_id, rating=rating, comment=comment
|
|
530
|
+
)
|
|
531
|
+
except ApiError as e:
|
|
532
|
+
return {"error": str(e), "status": e.status, "ticket_id": ticket_id}
|
|
533
|
+
return {
|
|
534
|
+
"ticket_id": result.get("ticket_id"),
|
|
535
|
+
"rating": result.get("rating"),
|
|
536
|
+
"ok": True,
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
@mcp.tool()
|
|
541
|
+
async def actionlayer_list_skills(
|
|
542
|
+
source_format: Optional[str] = None,
|
|
543
|
+
) -> dict[str, Any]:
|
|
544
|
+
"""List skills registered for this user.
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
source_format: Optional filter — "agentskills", "hermes",
|
|
548
|
+
"openclaw", "claude-code". Omit for all.
|
|
549
|
+
|
|
550
|
+
Returns {"skills": [{id, name, description, source_format, ...}, ...]}.
|
|
551
|
+
"""
|
|
552
|
+
client = _get_client()
|
|
553
|
+
try:
|
|
554
|
+
skills = await client.list_skills(source_format=source_format)
|
|
555
|
+
except ApiError as e:
|
|
556
|
+
return {"error": str(e), "status": e.status, "skills": []}
|
|
557
|
+
return {
|
|
558
|
+
"skills": [
|
|
559
|
+
{
|
|
560
|
+
"id": s.get("id"),
|
|
561
|
+
"name": s.get("name"),
|
|
562
|
+
"description": s.get("description"),
|
|
563
|
+
"source_format": s.get("source_format"),
|
|
564
|
+
"lifecycle": s.get("lifecycle"),
|
|
565
|
+
}
|
|
566
|
+
for s in skills
|
|
567
|
+
]
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
# --- Action catalog tools (P10) --------------------------------------
|
|
572
|
+
#
|
|
573
|
+
# These expose the typed action surface. Agents that prefer structured
|
|
574
|
+
# tool-use (vs. natural-language goals) should call these.
|
|
575
|
+
#
|
|
576
|
+
# Note: we surface the catalog as ONE generic tool
|
|
577
|
+
# (actionlayer_invoke_action) parameterized by action_id, rather than
|
|
578
|
+
# emitting 22+ separate MCP tools (gmail_send_email, gmail_summarize_inbox,
|
|
579
|
+
# ...). Build doc §16 calls this out — "one connector covers all 30+
|
|
580
|
+
# actions". The single-tool model matches Composio's surface and keeps
|
|
581
|
+
# the agent host's tool-discovery cost flat as we add actions.
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
# actionlayer_list_actions: prose capability sketch.
|
|
585
|
+
#
|
|
586
|
+
# History: this tool used to return a structured catalog of typed
|
|
587
|
+
# action ids (resy.book_table, partiful.create_event, ...). Hosts
|
|
588
|
+
# read that as the menu of what ActionLayer can do and presented it
|
|
589
|
+
# as a fixed catalog, the opposite of the intent. Removing the tool
|
|
590
|
+
# entirely (the 2026-05-30 morning fix) over-corrected — hosts then
|
|
591
|
+
# got "Unknown tool" errors when they reached for a familiar name.
|
|
592
|
+
#
|
|
593
|
+
# Now: the tool exists and returns prose only. No typed ids, no
|
|
594
|
+
# provider/category filters, no list shape. The body describes what
|
|
595
|
+
# ActionLayer can do, gives concrete use-case examples woven through
|
|
596
|
+
# as natural language, and points the host at actionlayer_start_task
|
|
597
|
+
# or actionlayer_invoke_action(action_id='direct.browser_action').
|
|
598
|
+
# Provider names appear inside example sentences but never as a list,
|
|
599
|
+
# so the host can't read them as a fixed catalog.
|
|
600
|
+
#
|
|
601
|
+
# actionlayer_get_action stays removed (also 2026-05-30 morning):
|
|
602
|
+
# there's no schema to fetch when typed ids are not advertised.
|
|
603
|
+
#
|
|
604
|
+
# Keep _CAPABILITY_SKETCH below in sync with the parallel constant
|
|
605
|
+
# in actionlayer/mcp/remote.py.
|
|
606
|
+
|
|
607
|
+
_CAPABILITY_SKETCH = (
|
|
608
|
+
"ActionLayer drives a real browser end-to-end to finish jobs on "
|
|
609
|
+
"any website. Most agents quit at the hard part — a CAPTCHA, a "
|
|
610
|
+
"novel checkout, a login wall, a registrar form, a hosting "
|
|
611
|
+
"console. ActionLayer doesn't: it reads the page, decides, "
|
|
612
|
+
"clicks, types, retries, and keeps moving until the goal is "
|
|
613
|
+
"reached. There is no fixed catalog of supported sites — if the "
|
|
614
|
+
"user can reach the site in a browser, ActionLayer can drive "
|
|
615
|
+
"it.\n\n"
|
|
616
|
+
"TWO WAYS TO INVOKE.\n"
|
|
617
|
+
"1. Natural-language goals: actionlayer_start_task(goal=\"...\"). "
|
|
618
|
+
"Use this when the request has the shape of an end-to-end job and "
|
|
619
|
+
"the user has given you specifics. Example goals: \"Book a table "
|
|
620
|
+
"for 4 at Lilia in Brooklyn next Friday at 7pm under the name "
|
|
621
|
+
"Maria.\" \"Order a 12oz oat milk latte from the Blue Bottle "
|
|
622
|
+
"closest to 30 Rockefeller Plaza for pickup in 20 minutes.\" "
|
|
623
|
+
"\"File a USPS Click-N-Ship label from 11201 to 94110 for a "
|
|
624
|
+
"1-pound flat-rate envelope.\" The agent plans, navigates, and "
|
|
625
|
+
"completes. It will ask the user for login credentials or a "
|
|
626
|
+
"payment confirmation when the site needs them — you don't have "
|
|
627
|
+
"to pre-arrange auth.\n"
|
|
628
|
+
"2. Direct-URL instructions: actionlayer_invoke_action with "
|
|
629
|
+
"action_id=\"direct.browser_action\" and inputs={url, "
|
|
630
|
+
"instruction}. Use this when you already know which page to "
|
|
631
|
+
"land on. Example: url=\"https://en.wikipedia.org/wiki/Ada_"
|
|
632
|
+
"Lovelace\", instruction=\"Find her date of birth and the "
|
|
633
|
+
"calculating engine she helped describe; return both with the "
|
|
634
|
+
"section heading they came from.\" The agent treats page "
|
|
635
|
+
"content as data, not instructions, so prompt-injection text "
|
|
636
|
+
"on the page can't redirect it.\n\n"
|
|
637
|
+
"WHAT WORKS. Real-world bookings and reservations. Multi-step "
|
|
638
|
+
"checkouts including 3DS challenges. Sites behind a login wall "
|
|
639
|
+
"(the user gets asked for credentials once and they're held "
|
|
640
|
+
"ephemerally). Sites that throw CAPTCHAs or bot walls — those "
|
|
641
|
+
"don't break the ticket; ActionLayer keeps working them. "
|
|
642
|
+
"Form-heavy government and shipping sites. Read-only research "
|
|
643
|
+
"across paginated content. Multi-site jobs that involve hand-"
|
|
644
|
+
"offs (use actionlayer_create_job for those).\n\n"
|
|
645
|
+
"WHAT IT'S NOT. Not a text-generation or summarization tool — "
|
|
646
|
+
"those are yours. Not a database query language — if the answer "
|
|
647
|
+
"is in your own knowledge, use that; ActionLayer is for jobs "
|
|
648
|
+
"that require visiting a live site. Not synchronous — browser "
|
|
649
|
+
"tasks return outcome=\"queued\" with a ticket_id; poll "
|
|
650
|
+
"actionlayer_get_task until is_terminal=true.\n\n"
|
|
651
|
+
"BE SPECIFIC. The single best predictor of whether a task "
|
|
652
|
+
"succeeds is whether the goal you pass uniquely identifies what "
|
|
653
|
+
"the user wants. Vague goals (\"book me dinner\", \"order "
|
|
654
|
+
"something\") block on the user and burn the planning budget. "
|
|
655
|
+
"Gather the specifics from the user first — date, time, party "
|
|
656
|
+
"size, address, exact item — then call start_task."
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
@mcp.tool()
|
|
661
|
+
async def actionlayer_list_actions() -> dict[str, Any]:
|
|
662
|
+
"""What ActionLayer can do, as prose. Read this when you're
|
|
663
|
+
deciding whether ActionLayer fits the user's request, or when
|
|
664
|
+
you're picking which entry point to call. The response is a
|
|
665
|
+
single 'description' field — there is no catalog of typed
|
|
666
|
+
actions to enumerate, by design.
|
|
667
|
+
"""
|
|
668
|
+
return {"description": _CAPABILITY_SKETCH}
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
@mcp.tool()
|
|
672
|
+
async def actionlayer_invoke_action(
|
|
673
|
+
action_id: str,
|
|
674
|
+
inputs: dict[str, Any],
|
|
675
|
+
executor: Optional[str] = None,
|
|
676
|
+
) -> dict[str, Any]:
|
|
677
|
+
"""Invoke a single action with typed inputs.
|
|
678
|
+
|
|
679
|
+
Two response shapes depending on the action's executor:
|
|
680
|
+
|
|
681
|
+
API actions — run synchronously. Returns outcome 'succeeded' /
|
|
682
|
+
'blocked' / 'failed' with output inline. Total time <5s typical.
|
|
683
|
+
|
|
684
|
+
Browser actions — return outcome='queued' immediately with
|
|
685
|
+
payload.ticket_id. These run in the background (30s to several
|
|
686
|
+
minutes). DO NOT RETRY on queued — retrying double-charges the
|
|
687
|
+
browser session and creates duplicate work. Instead:
|
|
688
|
+
|
|
689
|
+
1. Read payload.ticket_id from the response.
|
|
690
|
+
2. Call actionlayer_get_action_ticket(ticket_id) every 5
|
|
691
|
+
seconds.
|
|
692
|
+
3. Stop polling when is_terminal=true. Final outcome is in
|
|
693
|
+
the same field shape as sync (succeeded / blocked / failed).
|
|
694
|
+
4. If outcome=='blocked', the ticket is waiting on the USER
|
|
695
|
+
to provide something (e.g. login credentials via the
|
|
696
|
+
creds_prompt field). Do NOT re-call
|
|
697
|
+
actionlayer_invoke_action — the ticket is still alive.
|
|
698
|
+
|
|
699
|
+
Args:
|
|
700
|
+
action_id: Action id from actionlayer_list_actions
|
|
701
|
+
(e.g. 'resy.book_table').
|
|
702
|
+
inputs: Typed inputs matching the action's input_schema.
|
|
703
|
+
Required fields and types are visible via
|
|
704
|
+
actionlayer_get_action. Extra fields are rejected (422).
|
|
705
|
+
executor: Optional 'api' or 'browser'. When unset, the
|
|
706
|
+
dispatcher picks the action's preferred kind.
|
|
707
|
+
|
|
708
|
+
Returns:
|
|
709
|
+
action_id, executor_used, outcome, output, error,
|
|
710
|
+
blocked_reason, cost_usd, payload
|
|
711
|
+
(payload always contains ticket_id; when outcome=='queued',
|
|
712
|
+
payload also contains poll_path).
|
|
713
|
+
|
|
714
|
+
Common error responses:
|
|
715
|
+
404 — action_id not in the catalog
|
|
716
|
+
412 — auth not connected for this action's provider (the
|
|
717
|
+
user needs to complete the browser login, or connect
|
|
718
|
+
OAuth where supported, before retrying)
|
|
719
|
+
422 — inputs failed input_schema validation
|
|
720
|
+
"""
|
|
721
|
+
# Cap size + depth on the inputs dict before any network round
|
|
722
|
+
# trip. The Pydantic schema check happens server-side, but a
|
|
723
|
+
# multi-MB nested dict OOMs the API process during parse before
|
|
724
|
+
# the schema rejects it. SEC-MCP-012.
|
|
725
|
+
if _inputs_too_big(inputs):
|
|
726
|
+
return {
|
|
727
|
+
"error": (
|
|
728
|
+
"inputs payload exceeds size/depth cap "
|
|
729
|
+
"(64KB serialized or >10 nested levels)."
|
|
730
|
+
),
|
|
731
|
+
"status": 422,
|
|
732
|
+
"action_id": action_id,
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
client = _get_client()
|
|
736
|
+
try:
|
|
737
|
+
return await client.invoke_action(
|
|
738
|
+
action_id, inputs=inputs, executor=executor
|
|
739
|
+
)
|
|
740
|
+
except ApiError as e:
|
|
741
|
+
return {"error": str(e), "status": e.status, "action_id": action_id}
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _inputs_too_big(inputs: dict[str, Any]) -> bool:
|
|
745
|
+
"""True if `inputs` exceeds the size or nesting cap.
|
|
746
|
+
|
|
747
|
+
Size cap is on the serialized JSON form so it scales with what
|
|
748
|
+
the server will see, not the in-memory Python footprint. Depth
|
|
749
|
+
cap is on nested dict/list structure so a deeply-nested DoS
|
|
750
|
+
can't blow the recursion budget.
|
|
751
|
+
"""
|
|
752
|
+
import json as _json
|
|
753
|
+
|
|
754
|
+
try:
|
|
755
|
+
if len(_json.dumps(inputs, separators=(",", ":"))) > 64 * 1024:
|
|
756
|
+
return True
|
|
757
|
+
except (TypeError, ValueError):
|
|
758
|
+
return True
|
|
759
|
+
|
|
760
|
+
def _depth(value: Any, current: int = 0) -> int:
|
|
761
|
+
if current > 10:
|
|
762
|
+
return current
|
|
763
|
+
if isinstance(value, dict):
|
|
764
|
+
return max(
|
|
765
|
+
(_depth(v, current + 1) for v in value.values()),
|
|
766
|
+
default=current,
|
|
767
|
+
)
|
|
768
|
+
if isinstance(value, list):
|
|
769
|
+
return max(
|
|
770
|
+
(_depth(v, current + 1) for v in value),
|
|
771
|
+
default=current,
|
|
772
|
+
)
|
|
773
|
+
return current
|
|
774
|
+
|
|
775
|
+
return _depth(inputs) > 10
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
@mcp.tool()
|
|
779
|
+
async def actionlayer_get_action_ticket(ticket_id: str) -> dict[str, Any]:
|
|
780
|
+
"""Poll an action ticket for its current state.
|
|
781
|
+
|
|
782
|
+
Use after actionlayer_invoke_action returns outcome='queued'.
|
|
783
|
+
Recommended cadence: 5 seconds between polls (browser actions
|
|
784
|
+
typically take 30s-5min; faster polling just wastes calls).
|
|
785
|
+
|
|
786
|
+
Stop polling when is_terminal=true. At that point:
|
|
787
|
+
* outcome=='succeeded' — output contains the typed result;
|
|
788
|
+
`reason` carries a free-text summary when one is available.
|
|
789
|
+
* outcome=='blocked' — ActionLayer is waiting on the user to
|
|
790
|
+
provide something (e.g. login credentials via creds_prompt,
|
|
791
|
+
or a value via the standard reply flow). blocked_reason
|
|
792
|
+
names the ask ('creds_required' = login credentials).
|
|
793
|
+
Stop polling here and surface the prompt to the user.
|
|
794
|
+
* outcome=='failed' — `error` / `reason` explain why.
|
|
795
|
+
|
|
796
|
+
While outcome is None and is_terminal=false the ticket is in
|
|
797
|
+
`pending` — ActionLayer is still working on it; keep polling.
|
|
798
|
+
There is no sub-status to interpret.
|
|
799
|
+
|
|
800
|
+
Args:
|
|
801
|
+
ticket_id: from payload.ticket_id of the original
|
|
802
|
+
actionlayer_invoke_action response.
|
|
803
|
+
|
|
804
|
+
Returns:
|
|
805
|
+
ticket_id, action_id, state, outcome, output, error, reason,
|
|
806
|
+
blocked_reason, is_terminal
|
|
807
|
+
"""
|
|
808
|
+
client = _get_client()
|
|
809
|
+
try:
|
|
810
|
+
return await client.get_action_ticket(ticket_id)
|
|
811
|
+
except ApiError as e:
|
|
812
|
+
return {"error": str(e), "status": e.status, "ticket_id": ticket_id}
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
# --- Credential tools (per-task auth) ---------------------------------
|
|
816
|
+
#
|
|
817
|
+
# Browser actions that hit a login wall pause the ticket in
|
|
818
|
+
# blocked_on_user with a creds_required prompt. The user (or the LLM
|
|
819
|
+
# reading host secrets) provides credentials via
|
|
820
|
+
# actionlayer_provide_credentials. The worker injects them into the
|
|
821
|
+
# in-flight browser session for that ONE execution, then wipes
|
|
822
|
+
# the values. Never persisted to disk.
|
|
823
|
+
#
|
|
824
|
+
# Optional same-session reuse (remember_for_session=true) keeps the
|
|
825
|
+
# creds in Redis with a 1-hour TTL keyed by (user, site). Wiped on
|
|
826
|
+
# job completion or TTL expiry.
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
@mcp.tool()
|
|
830
|
+
async def actionlayer_provide_credentials(
|
|
831
|
+
ticket_id: str,
|
|
832
|
+
fields: dict[str, str],
|
|
833
|
+
remember_for_session: bool = False,
|
|
834
|
+
) -> dict[str, Any]:
|
|
835
|
+
"""Submit credentials for an action ticket blocked with creds_required.
|
|
836
|
+
|
|
837
|
+
Use this AFTER actionlayer_get_action_ticket shows a creds_prompt
|
|
838
|
+
in the response (state=blocked_on_user, blocked_reason=user,
|
|
839
|
+
creds_prompt.reason='creds_required'). The prompt's `fields`
|
|
840
|
+
list tells you what keys to send.
|
|
841
|
+
|
|
842
|
+
Credentials are encrypted in transit, stored in Redis with a
|
|
843
|
+
15-minute TTL, popped atomically by the worker (single-use), and
|
|
844
|
+
wiped from in-process memory after the agent loop returns. They
|
|
845
|
+
are NEVER written to Postgres or any persistent log.
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
ticket_id: From the original action invocation.
|
|
849
|
+
fields: Dict mapping each field name from creds_prompt.fields
|
|
850
|
+
to its value (e.g. {"email": "...", "password": "..."}).
|
|
851
|
+
remember_for_session: If True, ALSO keep an encrypted copy
|
|
852
|
+
in Redis for 1 hour, scoped to (this user, this site).
|
|
853
|
+
Subsequent blocks for the SAME site during the same agent
|
|
854
|
+
session auto-resolve without re-prompting. Default False.
|
|
855
|
+
|
|
856
|
+
Returns:
|
|
857
|
+
{ticket_id, state, status} — state transitions to "executing".
|
|
858
|
+
The agent resumes within ~5s.
|
|
859
|
+
Poll actionlayer_get_action_ticket again to see the next state.
|
|
860
|
+
|
|
861
|
+
IMPORTANT: Do NOT echo credentials back to the user after this
|
|
862
|
+
call succeeds. They are now in transit to the worker — confirming
|
|
863
|
+
receipt is enough.
|
|
864
|
+
"""
|
|
865
|
+
client = _get_client()
|
|
866
|
+
try:
|
|
867
|
+
return await client.provide_credentials(
|
|
868
|
+
ticket_id,
|
|
869
|
+
fields=fields,
|
|
870
|
+
remember_for_session=remember_for_session,
|
|
871
|
+
)
|
|
872
|
+
except ApiError as e:
|
|
873
|
+
return {"error": str(e), "status": e.status, "ticket_id": ticket_id}
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
@mcp.tool()
|
|
877
|
+
async def actionlayer_read_host_secret(slot_name: str) -> dict[str, Any]:
|
|
878
|
+
"""Read a credential slot from the agent host's environment.
|
|
879
|
+
|
|
880
|
+
This lets the LLM check if the user has pre-configured a secret
|
|
881
|
+
(e.g. RESY_PASSWORD, GMAIL_PASSWORD) in their Claude Code env
|
|
882
|
+
BEFORE prompting them. Reads `os.environ[slot_name]` in the MCP
|
|
883
|
+
server process — the secret never travels to ActionLayer's
|
|
884
|
+
backend unless the LLM then passes it to
|
|
885
|
+
actionlayer_provide_credentials.
|
|
886
|
+
|
|
887
|
+
Recommended slot naming: `<SITE>_<FIELD>`, all caps, no spaces.
|
|
888
|
+
RESY_EMAIL, RESY_PASSWORD
|
|
889
|
+
GMAIL_EMAIL, GMAIL_PASSWORD
|
|
890
|
+
GITHUB_USERNAME, GITHUB_PASSWORD
|
|
891
|
+
|
|
892
|
+
Args:
|
|
893
|
+
slot_name: env var name to look up.
|
|
894
|
+
|
|
895
|
+
Returns:
|
|
896
|
+
{found: bool, value?: str}
|
|
897
|
+
When found=False, no value is set in the response — the LLM
|
|
898
|
+
should fall back to asking the user.
|
|
899
|
+
|
|
900
|
+
Security:
|
|
901
|
+
* The slot name is validated (uppercase + underscores only)
|
|
902
|
+
to prevent process-env exfiltration via slot_name='PATH'
|
|
903
|
+
etc.
|
|
904
|
+
* The MCP server logs slot_name but NEVER the value.
|
|
905
|
+
* Returning the value to the LLM means it enters the model
|
|
906
|
+
context. The user opting to set the env var is their
|
|
907
|
+
explicit consent to that.
|
|
908
|
+
"""
|
|
909
|
+
# Validate slot name: only allow A-Z, 0-9, underscore. Refuse
|
|
910
|
+
# anything that could read sensitive process env like PATH, HOME,
|
|
911
|
+
# LD_PRELOAD, etc. — accidentally readable but not via this tool.
|
|
912
|
+
import re
|
|
913
|
+
|
|
914
|
+
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", slot_name or ""):
|
|
915
|
+
return {
|
|
916
|
+
"found": False,
|
|
917
|
+
"error": (
|
|
918
|
+
f"Invalid slot name {slot_name!r}. Use uppercase "
|
|
919
|
+
"letters, digits, and underscores only — e.g. "
|
|
920
|
+
"'RESY_PASSWORD'."
|
|
921
|
+
),
|
|
922
|
+
}
|
|
923
|
+
value = os.environ.get(slot_name)
|
|
924
|
+
if not value:
|
|
925
|
+
return {"found": False}
|
|
926
|
+
return {"found": True, "value": value}
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
# --- Job tools (P10) -------------------------------------------------
|
|
930
|
+
#
|
|
931
|
+
# Jobs are for multi-step plans. Single actions stay on
|
|
932
|
+
# actionlayer_invoke_action (sync). Jobs are async — caller polls
|
|
933
|
+
# actionlayer_get_job until terminal.
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
@mcp.tool()
|
|
937
|
+
async def actionlayer_create_job(
|
|
938
|
+
goal: str,
|
|
939
|
+
budget_usd: Optional[float] = None,
|
|
940
|
+
deadline: Optional[str] = None,
|
|
941
|
+
webhook_url: Optional[str] = None,
|
|
942
|
+
) -> dict[str, Any]:
|
|
943
|
+
"""Create a multi-step job from a natural-language goal.
|
|
944
|
+
|
|
945
|
+
The planner (Claude Sonnet 4.6) decomposes the goal into N
|
|
946
|
+
ordered actions, possibly with output piping between them.
|
|
947
|
+
Examples:
|
|
948
|
+
"Find a 30-min slot next week, then email alex@x.com to
|
|
949
|
+
schedule it"
|
|
950
|
+
"Summarize my unread emails, then post the count to
|
|
951
|
+
Slack #general"
|
|
952
|
+
|
|
953
|
+
Returns the job in 'planning' state. Poll actionlayer_get_job
|
|
954
|
+
until state is one of: complete, partial, failed, cancelled,
|
|
955
|
+
blocked.
|
|
956
|
+
|
|
957
|
+
Use actionlayer_invoke_action for single-action goals — it's
|
|
958
|
+
sync and skips the planner round-trip.
|
|
959
|
+
|
|
960
|
+
Args:
|
|
961
|
+
goal: Natural-language description of the work.
|
|
962
|
+
budget_usd: Optional total spend cap.
|
|
963
|
+
deadline: Optional ISO 8601 deadline (e.g.
|
|
964
|
+
'2026-05-15T19:00:00-07:00'). Planner picks faster
|
|
965
|
+
actions when deadlines are tight.
|
|
966
|
+
webhook_url: HTTPS URL POSTed on terminal state. Optional.
|
|
967
|
+
|
|
968
|
+
Returns the Job row: {id, goal, state, plan, budget_usd, ...}.
|
|
969
|
+
"""
|
|
970
|
+
client = _get_client()
|
|
971
|
+
body: dict[str, Any] = {"goal": goal}
|
|
972
|
+
if budget_usd is not None:
|
|
973
|
+
body["budget_usd"] = budget_usd
|
|
974
|
+
if deadline is not None:
|
|
975
|
+
body["deadline"] = deadline
|
|
976
|
+
if webhook_url is not None:
|
|
977
|
+
body["webhook_url"] = webhook_url
|
|
978
|
+
try:
|
|
979
|
+
return await client.create_job(
|
|
980
|
+
goal=goal,
|
|
981
|
+
budget_usd=budget_usd,
|
|
982
|
+
deadline=deadline,
|
|
983
|
+
webhook_url=webhook_url,
|
|
984
|
+
)
|
|
985
|
+
except ApiError as e:
|
|
986
|
+
return {"error": str(e), "status": e.status}
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
@mcp.tool()
|
|
990
|
+
async def actionlayer_get_job(job_id: str) -> dict[str, Any]:
|
|
991
|
+
"""Read a job's current state + per-step items.
|
|
992
|
+
|
|
993
|
+
Terminal states: complete, partial, failed, cancelled. Non-terminal:
|
|
994
|
+
planning, executing, blocked.
|
|
995
|
+
|
|
996
|
+
`items` is the list of plan steps; each has its own state,
|
|
997
|
+
inputs, result, and (if blocked) the reason.
|
|
998
|
+
|
|
999
|
+
Returns the Job row with `items` populated.
|
|
1000
|
+
"""
|
|
1001
|
+
client = _get_client()
|
|
1002
|
+
try:
|
|
1003
|
+
return await client.get_job(job_id)
|
|
1004
|
+
except ApiError as e:
|
|
1005
|
+
return {"error": str(e), "status": e.status, "job_id": job_id}
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
@mcp.tool()
|
|
1009
|
+
async def actionlayer_cancel_job(job_id: str) -> dict[str, Any]:
|
|
1010
|
+
"""Cancel an in-flight job. Idempotent on terminal states.
|
|
1011
|
+
|
|
1012
|
+
Note: cancellation is cooperative-on-fetch — if a job is
|
|
1013
|
+
mid-step when this is called, the current step finishes before
|
|
1014
|
+
the job transitions to cancelled.
|
|
1015
|
+
"""
|
|
1016
|
+
client = _get_client()
|
|
1017
|
+
try:
|
|
1018
|
+
return await client.cancel_job(job_id)
|
|
1019
|
+
except ApiError as e:
|
|
1020
|
+
return {"error": str(e), "status": e.status, "job_id": job_id}
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
@mcp.tool()
|
|
1024
|
+
async def actionlayer_resume_job(job_id: str) -> dict[str, Any]:
|
|
1025
|
+
"""Resume a job that's in 'blocked' state.
|
|
1026
|
+
|
|
1027
|
+
Use after the user has resolved the blocker (e.g. replied with
|
|
1028
|
+
a 2FA code via actionlayer_reply, or provided credentials).
|
|
1029
|
+
The worker re-evaluates the plan and continues from where it
|
|
1030
|
+
paused.
|
|
1031
|
+
|
|
1032
|
+
Returns 409 if the job isn't in 'blocked' state.
|
|
1033
|
+
"""
|
|
1034
|
+
client = _get_client()
|
|
1035
|
+
try:
|
|
1036
|
+
return await client.resume_job(job_id)
|
|
1037
|
+
except ApiError as e:
|
|
1038
|
+
return {"error": str(e), "status": e.status, "job_id": job_id}
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
# --- Configure subcommand --------------------------------------------
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _configure(api_key: str, api_url: Optional[str]) -> None:
|
|
1045
|
+
"""Write a basic Claude Desktop / Claude Code MCP config snippet to stdout.
|
|
1046
|
+
|
|
1047
|
+
Doesn't auto-edit the user's config file — printing keeps the action
|
|
1048
|
+
visible and lets the user paste into whichever host they use.
|
|
1049
|
+
"""
|
|
1050
|
+
snippet = {
|
|
1051
|
+
"mcpServers": {
|
|
1052
|
+
"actionlayer": {
|
|
1053
|
+
"command": "uvx",
|
|
1054
|
+
"args": ["actionlayer"],
|
|
1055
|
+
"env": {
|
|
1056
|
+
"ACTIONLAYER_API_KEY": api_key,
|
|
1057
|
+
**({"ACTIONLAYER_API_URL": api_url} if api_url else {}),
|
|
1058
|
+
},
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
print("Add this to your MCP host config (Claude Desktop, Cursor, etc.):\n")
|
|
1063
|
+
print(json.dumps(snippet, indent=2))
|
|
1064
|
+
print()
|
|
1065
|
+
home_hint = Path.home() / "Library/Application Support/Claude/claude_desktop_config.json"
|
|
1066
|
+
if sys.platform == "darwin":
|
|
1067
|
+
print(f"On macOS, Claude Desktop config lives at:\n {home_hint}")
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
# --- Entry point -----------------------------------------------------
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def main() -> None:
|
|
1074
|
+
parser = argparse.ArgumentParser(
|
|
1075
|
+
prog="actionlayer",
|
|
1076
|
+
description="ActionLayer MCP server (Model Context Protocol).",
|
|
1077
|
+
)
|
|
1078
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
1079
|
+
|
|
1080
|
+
cfg = sub.add_parser(
|
|
1081
|
+
"configure",
|
|
1082
|
+
help="Print MCP host config snippet for a given API key.",
|
|
1083
|
+
)
|
|
1084
|
+
cfg.add_argument("--api-key", required=True)
|
|
1085
|
+
cfg.add_argument("--api-url", default=None)
|
|
1086
|
+
|
|
1087
|
+
args = parser.parse_args()
|
|
1088
|
+
|
|
1089
|
+
if args.cmd == "configure":
|
|
1090
|
+
_configure(args.api_key, args.api_url)
|
|
1091
|
+
return
|
|
1092
|
+
|
|
1093
|
+
# Default: run the MCP server over stdio. The host (Claude Code,
|
|
1094
|
+
# Cursor, etc.) launches us with stdin/stdout connected and speaks
|
|
1095
|
+
# MCP JSON-RPC. fastmcp.run() blocks until the host disconnects.
|
|
1096
|
+
if not os.environ.get("ACTIONLAYER_API_KEY"):
|
|
1097
|
+
print(
|
|
1098
|
+
"ACTIONLAYER_API_KEY env var is not set. Either set it in your "
|
|
1099
|
+
"MCP host config (recommended) or run "
|
|
1100
|
+
"`actionlayer configure --api-key ak_...` for setup help.",
|
|
1101
|
+
file=sys.stderr,
|
|
1102
|
+
)
|
|
1103
|
+
sys.exit(2)
|
|
1104
|
+
|
|
1105
|
+
try:
|
|
1106
|
+
asyncio.run(mcp.run_async())
|
|
1107
|
+
except KeyboardInterrupt:
|
|
1108
|
+
pass
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
if __name__ == "__main__":
|
|
1112
|
+
main()
|