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.
@@ -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()