ara-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: ["**"]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.11", "3.12"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - name: Install package and test dependencies
20
+ run: |
21
+ python -m pip install --upgrade pip
22
+ python -m pip install -e . pytest
23
+ - name: Run tests
24
+ run: pytest -q
25
+ - name: Build package
26
+ run: |
27
+ python -m pip install build
28
+ python -m build
@@ -0,0 +1,14 @@
1
+ .DS_Store
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ .pytest_cache/
7
+ .coverage
8
+ htmlcov/
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+ .venv/
13
+ .env
14
+ .runtime-key.local
ara_sdk-0.1.0/LICENSE ADDED
@@ -0,0 +1,27 @@
1
+ Ara SDK Proprietary License
2
+ Copyright (c) 2026 Ara. All rights reserved.
3
+
4
+ This software and all associated source code, binaries, APIs, documentation,
5
+ and examples (collectively, the "Software") are proprietary and confidential.
6
+
7
+ No rights are granted except by an explicit written commercial agreement
8
+ signed by Ara.
9
+
10
+ Without Ara's prior written permission, you may NOT:
11
+ - copy, modify, or create derivative works of the Software;
12
+ - distribute, sublicense, sell, lease, lend, transfer, or make the Software
13
+ available to any third party;
14
+ - reverse engineer, decompile, disassemble, or otherwise attempt to derive
15
+ source code from any non-source distribution;
16
+ - remove or alter any proprietary notices;
17
+ - use the Software to build, benchmark, or train a competing product.
18
+
19
+ Any unauthorized use is strictly prohibited and may result in immediate
20
+ termination of access, legal action, and/or damages.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS
24
+ FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, OR TITLE. TO THE MAXIMUM EXTENT
25
+ PERMITTED BY LAW, ARA SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL,
26
+ SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR FOR ANY LOSS OF DATA, PROFITS,
27
+ REVENUE, OR BUSINESS INTERRUPTION, ARISING OUT OF OR RELATED TO THE SOFTWARE.
ara_sdk-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: ara-sdk
3
+ Version: 0.1.0
4
+ Summary: Public Python SDK for building and running Ara apps.
5
+ Project-URL: Homepage, https://github.com/Aradotso/ara-python-sdk
6
+ Project-URL: Documentation, https://docs.ara.so/sdk/overview
7
+ Project-URL: Issues, https://github.com/Aradotso/ara-python-sdk/issues
8
+ Author-email: Ara <support@ara.so>
9
+ License: Proprietary
10
+ License-File: LICENSE
11
+ Keywords: agents,ai,ara,sdk
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: Other/Proprietary License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+
23
+ # Ara Python SDK
24
+
25
+ Public Python SDK for building Ara apps with a decorator-first workflow.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install ara-sdk
31
+ ```
32
+
33
+ ## Principles
34
+
35
+ - Public SDK is generic and provider-agnostic.
36
+ - Runtime policy, retries, and safety controls are enforced server-side.
37
+ - Optional integrations (Cal.com, CRM, etc.) live in examples, not in the core package.
38
+
39
+ ## Quickstart
40
+
41
+ ```python
42
+ from ara_sdk import App, cron, run_cli, sandbox
43
+
44
+ app = App("Investor Meeting Booker", project_name="investor-meeting-booking")
45
+
46
+ @app.subagent(handoff_to=["calendar-strategist"], sandbox=sandbox())
47
+ def booking_coordinator(event=None):
48
+ """Coordinate scheduling requests."""
49
+
50
+ @app.hook(id="daily-followups", event="scheduler.followups", schedule=cron("0 13 * * 1-5"))
51
+ def daily_followups():
52
+ """Send pending followups."""
53
+
54
+ if __name__ == "__main__":
55
+ run_cli(app)
56
+ ```
57
+
58
+ ```bash
59
+ export ARA_API_BASE_URL="https://api.ara.so"
60
+ export ARA_ACCESS_TOKEN="your_user_jwt"
61
+
62
+ python app.py deploy
63
+ python app.py run --workflow booking-coordinator --message "Need 3 slots next week"
64
+ python app.py events --event-type channel.web.inbound --channel web --message "hello"
65
+ python app.py setup
66
+ ```
67
+
68
+ ## Environment
69
+
70
+ - `ARA_API_BASE_URL`: Ara API base URL
71
+ - `ARA_ACCESS_TOKEN`: user JWT for control plane
72
+ - `ARA_RUNTIME_KEY`: optional runtime key override for run/events (otherwise `.runtime-key.local` is used)
73
+
74
+ ## Examples
75
+
76
+ See `examples/` for optional integrations and demo projects:
77
+
78
+ - `examples/calcom-booking/`
79
+
80
+ ## Security
81
+
82
+ - Never commit API keys, runtime keys, or provider secrets.
83
+ - Keep provider-specific credentials in environment variables.
84
+
85
+ ## License
86
+
87
+ This repository is source-available under a strict proprietary license.
88
+ Unauthorized copying, redistribution, or derivative works are prohibited.
89
+ See `LICENSE` for full terms.
@@ -0,0 +1,67 @@
1
+ # Ara Python SDK
2
+
3
+ Public Python SDK for building Ara apps with a decorator-first workflow.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install ara-sdk
9
+ ```
10
+
11
+ ## Principles
12
+
13
+ - Public SDK is generic and provider-agnostic.
14
+ - Runtime policy, retries, and safety controls are enforced server-side.
15
+ - Optional integrations (Cal.com, CRM, etc.) live in examples, not in the core package.
16
+
17
+ ## Quickstart
18
+
19
+ ```python
20
+ from ara_sdk import App, cron, run_cli, sandbox
21
+
22
+ app = App("Investor Meeting Booker", project_name="investor-meeting-booking")
23
+
24
+ @app.subagent(handoff_to=["calendar-strategist"], sandbox=sandbox())
25
+ def booking_coordinator(event=None):
26
+ """Coordinate scheduling requests."""
27
+
28
+ @app.hook(id="daily-followups", event="scheduler.followups", schedule=cron("0 13 * * 1-5"))
29
+ def daily_followups():
30
+ """Send pending followups."""
31
+
32
+ if __name__ == "__main__":
33
+ run_cli(app)
34
+ ```
35
+
36
+ ```bash
37
+ export ARA_API_BASE_URL="https://api.ara.so"
38
+ export ARA_ACCESS_TOKEN="your_user_jwt"
39
+
40
+ python app.py deploy
41
+ python app.py run --workflow booking-coordinator --message "Need 3 slots next week"
42
+ python app.py events --event-type channel.web.inbound --channel web --message "hello"
43
+ python app.py setup
44
+ ```
45
+
46
+ ## Environment
47
+
48
+ - `ARA_API_BASE_URL`: Ara API base URL
49
+ - `ARA_ACCESS_TOKEN`: user JWT for control plane
50
+ - `ARA_RUNTIME_KEY`: optional runtime key override for run/events (otherwise `.runtime-key.local` is used)
51
+
52
+ ## Examples
53
+
54
+ See `examples/` for optional integrations and demo projects:
55
+
56
+ - `examples/calcom-booking/`
57
+
58
+ ## Security
59
+
60
+ - Never commit API keys, runtime keys, or provider secrets.
61
+ - Keep provider-specific credentials in environment variables.
62
+
63
+ ## License
64
+
65
+ This repository is source-available under a strict proprietary license.
66
+ Unauthorized copying, redistribution, or derivative works are prohibited.
67
+ See `LICENSE` for full terms.
@@ -0,0 +1,24 @@
1
+ # Cal.com Booking Example
2
+
3
+ This folder is an optional integration example built on top of the public `ara-sdk`.
4
+
5
+ It is intentionally outside the core package so the SDK stays provider-agnostic.
6
+
7
+ ## What this demonstrates
8
+
9
+ - Turning inbound chat messages into booking intents
10
+ - Looking up next-week availability from Cal.com
11
+ - Optionally creating bookings for selected slots
12
+ - Forwarding enriched context to Ara app event ingress
13
+
14
+ ## Required environment variables
15
+
16
+ - `CALCOM_API_KEY`
17
+ - `CALCOM_EVENT_TYPE_ID` or `CALCOM_EVENT_TYPE_SLUG`
18
+ - `ARA_API_BASE_URL`
19
+ - `ARA_ACCESS_TOKEN`
20
+
21
+ ## Security
22
+
23
+ - Never hardcode API keys in code.
24
+ - Keep `.env` files out of version control.
@@ -0,0 +1,31 @@
1
+ from ara_sdk import App, cron, run_cli, sandbox
2
+
3
+ app = App(
4
+ "Meeting Booker",
5
+ project_name="meeting-booker",
6
+ description="Optional Cal.com-backed meeting booking flow.",
7
+ )
8
+
9
+
10
+ @app.subagent(
11
+ id="booking-coordinator",
12
+ workflow_id="booking-coordinator",
13
+ handoff_to=["calendar-strategist"],
14
+ sandbox=sandbox(max_concurrency=3),
15
+ )
16
+ def booking_coordinator(event=None):
17
+ """Coordinate scheduling and booking actions."""
18
+
19
+
20
+ @app.hook(
21
+ id="daily-followups",
22
+ event="scheduler.followups",
23
+ schedule=cron("0 13 * * 1-5"),
24
+ agent="booking-coordinator",
25
+ )
26
+ def daily_followups():
27
+ """Send reminders for pending confirmations."""
28
+
29
+
30
+ if __name__ == "__main__":
31
+ run_cli(app)
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ara-sdk"
7
+ version = "0.1.0"
8
+ description = "Public Python SDK for building and running Ara apps."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Proprietary" }
12
+ authors = [
13
+ { name = "Ara", email = "support@ara.so" }
14
+ ]
15
+ keywords = ["ara", "ai", "agents", "sdk"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: Other/Proprietary License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development :: Libraries :: Python Modules"
25
+ ]
26
+ dependencies = []
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/Aradotso/ara-python-sdk"
30
+ Documentation = "https://docs.ara.so/sdk/overview"
31
+ Issues = "https://github.com/Aradotso/ara-python-sdk/issues"
32
+
33
+ [project.scripts]
34
+ ara-sdk = "ara_sdk.__main__:main"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/ara_sdk"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
@@ -0,0 +1,27 @@
1
+ """Public Ara Python SDK."""
2
+
3
+ from .core import (
4
+ App,
5
+ AraClient,
6
+ cron,
7
+ entrypoint,
8
+ file,
9
+ local_file,
10
+ runtime,
11
+ run_cli,
12
+ sandbox,
13
+ subagent_hook,
14
+ )
15
+
16
+ __all__ = [
17
+ "App",
18
+ "AraClient",
19
+ "cron",
20
+ "entrypoint",
21
+ "file",
22
+ "local_file",
23
+ "runtime",
24
+ "run_cli",
25
+ "sandbox",
26
+ "subagent_hook",
27
+ ]
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import pathlib
5
+ import sys
6
+ from types import ModuleType
7
+
8
+ from .core import App, run_cli
9
+
10
+
11
+ def _load_module(path: pathlib.Path) -> ModuleType:
12
+ spec = importlib.util.spec_from_file_location("ara_user_app", str(path))
13
+ if spec is None or spec.loader is None:
14
+ raise RuntimeError(f"Could not load module from {path}")
15
+ module = importlib.util.module_from_spec(spec)
16
+ spec.loader.exec_module(module)
17
+ return module
18
+
19
+
20
+ def _discover_app(module: ModuleType) -> App:
21
+ for _, value in vars(module).items():
22
+ if isinstance(value, App):
23
+ return value
24
+ raise RuntimeError("No App(...) instance found in script")
25
+
26
+
27
+ def main() -> None:
28
+ if len(sys.argv) < 3:
29
+ raise SystemExit("Usage: ara-sdk <command> <app_script.py> [args...]")
30
+ command = sys.argv[1]
31
+ script = pathlib.Path(sys.argv[2]).expanduser().resolve()
32
+ if not script.exists():
33
+ raise SystemExit(f"Script not found: {script}")
34
+ module = _load_module(script)
35
+ app = _discover_app(module)
36
+ run_cli(app, argv=[command, *sys.argv[3:]], default_command=command)
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
@@ -0,0 +1,837 @@
1
+ """Public Ara Python SDK core (provider-agnostic)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import inspect
7
+ import json
8
+ import os
9
+ import pathlib
10
+ import urllib.error
11
+ import urllib.request
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Callable, Optional
14
+ from uuid import uuid4
15
+
16
+ DEFAULT_SUBAGENT_MAX_CONCURRENCY = 4
17
+ DEFAULT_TIMEOUT_SECONDS = 120
18
+ DEFAULT_MAX_RETRIES = 2
19
+ DEFAULT_RETRY_BACKOFF_SECONDS = 5
20
+ DEBUG_HTTP_ERRORS_ENV = "ARA_SDK_DEBUG_HTTP_ERRORS"
21
+
22
+
23
+ def _slugify(value: str) -> str:
24
+ out = []
25
+ prev_dash = False
26
+ for ch in str(value or "").strip().lower():
27
+ if ch.isalnum():
28
+ out.append(ch)
29
+ prev_dash = False
30
+ continue
31
+ if not prev_dash:
32
+ out.append("-")
33
+ prev_dash = True
34
+ slug = "".join(out).strip("-")
35
+ return slug[:120]
36
+
37
+
38
+ def _new_run_id() -> str:
39
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
40
+ return f"run-{ts}-{uuid4().hex[:8]}"
41
+
42
+
43
+ def _env_flag_enabled(key: str) -> bool:
44
+ return str(os.getenv(key, "")).strip().lower() in {"1", "true", "yes", "on"}
45
+
46
+
47
+ def file(path: str, content: str, *, executable: bool = False) -> dict[str, Any]:
48
+ path_value = str(path or "").strip()
49
+ if not path_value:
50
+ raise ValueError("file() requires a non-empty path")
51
+ return {"path": path_value, "content": str(content or ""), "executable": bool(executable)}
52
+
53
+
54
+ def local_file(
55
+ source: str | pathlib.Path,
56
+ path: Optional[str] = None,
57
+ *,
58
+ executable: bool = True,
59
+ encoding: str = "utf-8",
60
+ ) -> dict[str, Any]:
61
+ src = pathlib.Path(source)
62
+ if not src.exists() or not src.is_file():
63
+ raise ValueError(f"local_file() source not found: {src}")
64
+ target = str(path or src.name).strip()
65
+ if not target:
66
+ raise ValueError("local_file() requires a non-empty target path")
67
+ return file(target, src.read_text(encoding=encoding), executable=executable)
68
+
69
+
70
+ def entrypoint(command: str, *, shell: str = "bash", args: Optional[list[str]] = None) -> dict[str, Any]:
71
+ cmd = str(command or "").strip()
72
+ if not cmd:
73
+ raise ValueError("entrypoint() requires a non-empty command")
74
+ return {
75
+ "entrypoint": cmd,
76
+ "shell": str(shell or "bash").strip() or "bash",
77
+ "args": [str(a).strip() for a in (args or []) if str(a).strip()],
78
+ }
79
+
80
+
81
+ def runtime(
82
+ *,
83
+ files: Optional[list[dict[str, Any]]] = None,
84
+ startup: Optional[dict[str, Any]] = None,
85
+ image: Optional[str] = None,
86
+ memory_mb: Optional[int] = None,
87
+ volume_size_mb: Optional[int] = None,
88
+ python_packages: Optional[list[str]] = None,
89
+ node_packages: Optional[list[str]] = None,
90
+ ) -> dict[str, Any]:
91
+ profile: dict[str, Any] = {}
92
+ if files:
93
+ profile["files"] = [dict(item) for item in files]
94
+ if startup:
95
+ profile["startup"] = dict(startup)
96
+ if image:
97
+ profile["image"] = str(image).strip()
98
+ if memory_mb is not None:
99
+ profile["memory_mb"] = int(memory_mb)
100
+ if volume_size_mb is not None:
101
+ profile["volume_size_mb"] = int(volume_size_mb)
102
+ if python_packages:
103
+ profile["python_packages"] = [str(pkg).strip() for pkg in python_packages if str(pkg).strip()]
104
+ if node_packages:
105
+ profile["node_packages"] = [str(pkg).strip() for pkg in node_packages if str(pkg).strip()]
106
+ return profile
107
+
108
+
109
+ def cron(expression: str, *, timezone: str = "UTC") -> dict[str, Any]:
110
+ expr = str(expression or "").strip()
111
+ if not expr:
112
+ raise ValueError("cron() requires a non-empty expression")
113
+ return {"type": "cron", "cron": expr, "schedule": expr, "timezone": str(timezone or "UTC")}
114
+
115
+
116
+ def sandbox(
117
+ *,
118
+ policy: str = "shared",
119
+ max_concurrency: Optional[int] = None,
120
+ idle_ttl_minutes: Optional[int] = None,
121
+ ) -> dict[str, Any]:
122
+ normalized_policy = str(policy or "shared").strip().lower()
123
+ if normalized_policy != "shared":
124
+ raise ValueError("Public SDK currently supports only sandbox(policy='shared').")
125
+ out: dict[str, Any] = {"policy": "shared"}
126
+ out["max_concurrency"] = max(1, int(max_concurrency or DEFAULT_SUBAGENT_MAX_CONCURRENCY))
127
+ if idle_ttl_minutes is not None:
128
+ out["idle_ttl_minutes"] = max(1, int(idle_ttl_minutes))
129
+ return out
130
+
131
+
132
+ def subagent_hook(
133
+ *,
134
+ event: str,
135
+ id: Optional[str] = None,
136
+ task: Optional[str] = None,
137
+ command: Optional[str] = None,
138
+ trigger: Optional[dict[str, Any]] = None,
139
+ schedule: Optional[dict[str, Any] | str] = None,
140
+ channel: str = "api",
141
+ ) -> dict[str, Any]:
142
+ evt = str(event or "").strip()
143
+ if not evt:
144
+ raise ValueError("subagent_hook() requires event")
145
+ if task and command:
146
+ raise ValueError("subagent_hook() accepts either task= or command=, not both")
147
+ hook_id = str(id or "").strip() or f"{_slugify(evt)}-hook"
148
+ out: dict[str, Any] = {"id": hook_id, "event": evt, "channel": str(channel or "api").strip() or "api"}
149
+ if task:
150
+ out["task"] = str(task).strip()
151
+ if command:
152
+ out["command"] = str(command).strip()
153
+ if trigger and isinstance(trigger, dict):
154
+ out["trigger"] = dict(trigger)
155
+ if schedule is not None:
156
+ out["schedule"] = schedule
157
+ return out
158
+
159
+
160
+ def _normalize_trigger(
161
+ trigger: Optional[dict[str, Any]],
162
+ schedule: Optional[dict[str, Any] | str],
163
+ ) -> tuple[dict[str, Any], str]:
164
+ trigger_cfg = dict(trigger) if isinstance(trigger, dict) else {}
165
+ schedule_expr = ""
166
+ if isinstance(schedule, dict):
167
+ schedule_expr = str(schedule.get("cron") or schedule.get("schedule") or "").strip()
168
+ trigger_cfg.setdefault("type", str(schedule.get("type") or "cron").strip() or "cron")
169
+ if schedule_expr:
170
+ trigger_cfg.setdefault("cron", schedule_expr)
171
+ trigger_cfg.setdefault("schedule", schedule_expr)
172
+ if schedule.get("timezone"):
173
+ trigger_cfg.setdefault("timezone", str(schedule.get("timezone")))
174
+ elif isinstance(schedule, str):
175
+ schedule_expr = schedule.strip()
176
+ if schedule_expr:
177
+ trigger_cfg.setdefault("type", "cron")
178
+ trigger_cfg.setdefault("cron", schedule_expr)
179
+ trigger_cfg.setdefault("schedule", schedule_expr)
180
+ else:
181
+ schedule_expr = str(trigger_cfg.get("cron") or trigger_cfg.get("schedule") or "").strip()
182
+ if not trigger_cfg:
183
+ trigger_cfg = {"type": "api"}
184
+ if "type" not in trigger_cfg:
185
+ trigger_cfg["type"] = "api"
186
+ return trigger_cfg, schedule_expr
187
+
188
+
189
+ class App:
190
+ """Public app declaration object."""
191
+
192
+ def __init__(
193
+ self,
194
+ name: str,
195
+ *,
196
+ slug: Optional[str] = None,
197
+ project_name: Optional[str] = None,
198
+ description: str = "",
199
+ interfaces: Optional[dict[str, Any]] = None,
200
+ runtime_profile: Optional[dict[str, Any]] = None,
201
+ agent: Optional[dict[str, Any]] = None,
202
+ ):
203
+ self.name = str(name or "").strip()
204
+ self.project_name = str(project_name or "").strip()
205
+ source = self.project_name or slug or self.name
206
+ self.slug = _slugify(source)
207
+ if not self.name:
208
+ raise ValueError("App(name=...) requires a non-empty name")
209
+ if not self.slug:
210
+ raise ValueError("App(...) could not derive a slug")
211
+ self.description = str(description or "").strip()
212
+ self._agent = dict(agent or {})
213
+ self._interfaces = dict(interfaces or {})
214
+ self._runtime_profile = dict(runtime_profile or {})
215
+ self._workflows: list[dict[str, Any]] = []
216
+ self._profiles: list[dict[str, Any]] = []
217
+ self._subagents: list[dict[str, Any]] = []
218
+ self._local_entrypoint: Optional[Callable[..., Any]] = None
219
+
220
+ def _upsert(self, rows: list[dict[str, Any]], item: dict[str, Any], *, key: str = "id") -> None:
221
+ item_key = str(item.get(key) or "")
222
+ if not item_key:
223
+ return
224
+ for idx, existing in enumerate(rows):
225
+ if str(existing.get(key) or "") == item_key:
226
+ rows[idx] = item
227
+ return
228
+ rows.append(item)
229
+
230
+ def agent(
231
+ self,
232
+ id: Optional[str] = None,
233
+ *,
234
+ instructions: str = "",
235
+ handoff_to: Optional[list[str]] = None,
236
+ always_on: bool = True,
237
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
238
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
239
+ profile_id = str(id or _slugify(fn.__name__.replace("_", "-"))).strip()
240
+ if not profile_id:
241
+ raise ValueError("@app.agent requires a non-empty id")
242
+ text = str(instructions or fn.__doc__ or "").strip()
243
+ profile = {
244
+ "id": profile_id,
245
+ "instructions": text,
246
+ "persona": text,
247
+ "handoff_to": [str(x).strip() for x in (handoff_to or []) if str(x).strip()],
248
+ "always_on": bool(always_on),
249
+ }
250
+ self._upsert(self._profiles, profile)
251
+ setattr(fn, "__ara_agent_profile__", profile)
252
+ return fn
253
+
254
+ return decorator
255
+
256
+ def task(
257
+ self,
258
+ *,
259
+ id: Optional[str] = None,
260
+ agent: Optional[str] = None,
261
+ task: Optional[str] = None,
262
+ trigger: Optional[dict[str, Any]] = None,
263
+ schedule: Optional[dict[str, Any] | str] = None,
264
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
265
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
266
+ workflow_id = str(id or _slugify(fn.__name__.replace("_", "-"))).strip()
267
+ if not workflow_id:
268
+ raise ValueError("@app.task requires a non-empty id")
269
+ trigger_cfg, schedule_expr = _normalize_trigger(trigger, schedule)
270
+ item: dict[str, Any] = {
271
+ "id": workflow_id,
272
+ "mode": "task",
273
+ "task": str(task or fn.__doc__ or "").strip() or f"Execute workflow {workflow_id}",
274
+ "trigger": trigger_cfg,
275
+ "run": {},
276
+ "pipeline": [],
277
+ }
278
+ if agent:
279
+ item["agent_id"] = str(agent).strip()
280
+ if schedule_expr:
281
+ item["schedule"] = schedule_expr
282
+ self._upsert(self._workflows, item)
283
+ setattr(fn, "__ara_workflow__", item)
284
+ return fn
285
+
286
+ return decorator
287
+
288
+ def hook(
289
+ self,
290
+ *,
291
+ id: Optional[str] = None,
292
+ event: str = "hook.tick",
293
+ agent: Optional[str] = None,
294
+ task: Optional[str] = None,
295
+ command: Optional[str] = None,
296
+ trigger: Optional[dict[str, Any]] = None,
297
+ schedule: Optional[dict[str, Any] | str] = None,
298
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
299
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
300
+ workflow_id = str(id or _slugify(fn.__name__.replace("_", "-"))).strip()
301
+ if not workflow_id:
302
+ raise ValueError("@app.hook requires a non-empty id")
303
+ event_name = str(event or "hook.tick").strip() or "hook.tick"
304
+ trigger_cfg = dict(trigger or {})
305
+ trigger_cfg.setdefault("type", "api")
306
+ trigger_cfg.setdefault("event", event_name)
307
+ if command:
308
+ self._upsert(
309
+ self._workflows,
310
+ {
311
+ "id": workflow_id,
312
+ "mode": "run",
313
+ "task": "",
314
+ "run": {"command": str(command).strip()},
315
+ "pipeline": [],
316
+ "trigger": trigger_cfg,
317
+ "schedule": str(schedule or "").strip() if isinstance(schedule, str) else "",
318
+ },
319
+ )
320
+ else:
321
+ self.task(
322
+ id=workflow_id,
323
+ agent=agent,
324
+ task=str(task or fn.__doc__ or "").strip() or f"Handle hook '{event_name}'",
325
+ trigger=trigger_cfg,
326
+ schedule=schedule,
327
+ )(fn)
328
+ setattr(fn, "__ara_hook__", {"id": workflow_id, "event": event_name})
329
+ return fn
330
+
331
+ return decorator
332
+
333
+ def subagent(
334
+ self,
335
+ id: Optional[str] = None,
336
+ *,
337
+ workflow_id: Optional[str] = None,
338
+ instructions: str = "",
339
+ handoff_to: Optional[list[str]] = None,
340
+ always_on: bool = True,
341
+ task: Optional[str] = None,
342
+ trigger: Optional[dict[str, Any]] = None,
343
+ schedule: Optional[dict[str, Any] | str] = None,
344
+ runtime: Optional[dict[str, Any]] = None,
345
+ sandbox: Optional[dict[str, Any]] = None,
346
+ channels: Optional[list[str]] = None,
347
+ hooks: Optional[list[dict[str, Any]]] = None,
348
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
349
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
350
+ profile_id = str(id or _slugify(fn.__name__.replace("_", "-"))).strip()
351
+ wf_id = str(workflow_id or profile_id).strip()
352
+ if not profile_id or not wf_id:
353
+ raise ValueError("@app.subagent requires non-empty id/workflow_id")
354
+ self.agent(
355
+ profile_id,
356
+ instructions=instructions,
357
+ handoff_to=handoff_to,
358
+ always_on=always_on,
359
+ )(fn)
360
+ self.task(
361
+ id=wf_id,
362
+ agent=profile_id,
363
+ task=str(task or fn.__doc__ or "").strip() or f"Execute subagent {profile_id}",
364
+ trigger=trigger,
365
+ schedule=schedule,
366
+ )(fn)
367
+ sub = {
368
+ "id": profile_id,
369
+ "workflow_id": wf_id,
370
+ "channels": sorted({str(c).strip().lower() for c in (channels or []) if str(c).strip()}),
371
+ "runtime": dict(runtime or {}),
372
+ "sandbox": dict(sandbox or {"policy": "shared", "max_concurrency": DEFAULT_SUBAGENT_MAX_CONCURRENCY}),
373
+ "hooks": [dict(h) for h in (hooks or []) if isinstance(h, dict)],
374
+ }
375
+ self._upsert(self._subagents, sub)
376
+ setattr(fn, "__ara_subagent__", sub)
377
+ return fn
378
+
379
+ return decorator
380
+
381
+ def local_entrypoint(self) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
382
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
383
+ self._local_entrypoint = fn
384
+ return fn
385
+
386
+ return decorator
387
+
388
+ def call_local_entrypoint(self, input_payload: dict[str, str]) -> Any:
389
+ if self._local_entrypoint is None:
390
+ raise RuntimeError("No @app.local_entrypoint() registered")
391
+ fn = self._local_entrypoint
392
+ params = list(inspect.signature(fn).parameters.values())
393
+ if not params:
394
+ return fn()
395
+ if len(params) == 1:
396
+ return fn(input_payload)
397
+ kwargs = {p.name: input_payload[p.name] for p in params if p.name in input_payload}
398
+ return fn(**kwargs)
399
+
400
+ @property
401
+ def manifest(self) -> dict[str, Any]:
402
+ agent = dict(self._agent)
403
+ if self._profiles:
404
+ agent["profiles"] = list(self._profiles)
405
+ agent.setdefault("default_profile_id", str(self._profiles[0].get("id") or "default"))
406
+ if self._subagents:
407
+ agent["subagents"] = list(self._subagents)
408
+ return {
409
+ "name": self.name,
410
+ "slug": self.slug,
411
+ "description": self.description,
412
+ "agent": agent,
413
+ "workflows": list(self._workflows),
414
+ "interfaces": dict(self._interfaces),
415
+ "runtime_profile": dict(self._runtime_profile),
416
+ }
417
+
418
+
419
+ def _read_dotenv(path: pathlib.Path) -> None:
420
+ if not path.exists():
421
+ return
422
+ for raw in path.read_text(encoding="utf-8").splitlines():
423
+ line = raw.strip()
424
+ if not line or line.startswith("#") or "=" not in line:
425
+ continue
426
+ key, value = line.split("=", 1)
427
+ key = key.strip()
428
+ value = value.strip().strip("'").strip('"')
429
+ if key and not os.getenv(key):
430
+ os.environ[key] = value
431
+
432
+
433
+ def _require_env(*keys: str) -> dict[str, str]:
434
+ out: dict[str, str] = {}
435
+ missing: list[str] = []
436
+ for key in keys:
437
+ value = os.getenv(key, "").strip()
438
+ if not value:
439
+ missing.append(key)
440
+ else:
441
+ out[key] = value
442
+ if missing:
443
+ raise RuntimeError(
444
+ "Missing required env vars: " + ", ".join(missing) + ". "
445
+ "Create .env or export variables before running this command."
446
+ )
447
+ return out
448
+
449
+
450
+ class _Http:
451
+ def __init__(self, base_url: str, access_token: str):
452
+ self.base_url = base_url.rstrip("/")
453
+ self.access_token = access_token
454
+
455
+ def _request(
456
+ self,
457
+ path: str,
458
+ *,
459
+ method: str = "GET",
460
+ body: Optional[dict[str, Any]] = None,
461
+ headers: Optional[dict[str, str]] = None,
462
+ auth_header: Optional[str] = None,
463
+ ) -> Any:
464
+ url = f"{self.base_url}{path}"
465
+ payload = None if body is None else json.dumps(body).encode("utf-8")
466
+ req_headers = {
467
+ "Content-Type": "application/json",
468
+ "Authorization": auth_header or f"Bearer {self.access_token}",
469
+ }
470
+ if headers:
471
+ req_headers.update(headers)
472
+ req = urllib.request.Request(url, method=method, data=payload, headers=req_headers)
473
+ try:
474
+ with urllib.request.urlopen(req, timeout=30) as response:
475
+ if response.status == 204:
476
+ return None
477
+ raw = response.read().decode("utf-8")
478
+ return json.loads(raw) if raw else {}
479
+ except urllib.error.HTTPError as exc:
480
+ details = exc.read().decode("utf-8", errors="replace")
481
+ if _env_flag_enabled(DEBUG_HTTP_ERRORS_ENV):
482
+ raise RuntimeError(f"{method} {path} failed ({exc.code}): {details}") from exc
483
+ raise RuntimeError(
484
+ f"{method} {path} failed ({exc.code}). "
485
+ f"Response body hidden by default; set {DEBUG_HTTP_ERRORS_ENV}=true to include it."
486
+ ) from exc
487
+
488
+ def list_apps(self) -> dict[str, Any]:
489
+ return self._request("/apps")
490
+
491
+ def create_app(self, body: dict[str, Any]) -> dict[str, Any]:
492
+ return self._request("/apps", method="POST", body=body)
493
+
494
+ def update_app(self, app_id: str, body: dict[str, Any]) -> dict[str, Any]:
495
+ return self._request(f"/apps/{app_id}", method="PATCH", body=body)
496
+
497
+ def create_key(self, app_id: str, *, name: str, requests_per_minute: int) -> dict[str, Any]:
498
+ return self._request(
499
+ f"/apps/{app_id}/keys",
500
+ method="POST",
501
+ body={"name": name, "requests_per_minute": int(requests_per_minute)},
502
+ )
503
+
504
+ def run_app(self, app_id: str, *, runtime_key: str, workflow_id: Optional[str], input_payload: dict[str, Any], warmup: bool = False):
505
+ return self._request(
506
+ f"/v1/apps/{app_id}/run",
507
+ method="POST",
508
+ body={"workflow_id": workflow_id, "warmup": bool(warmup), "input": input_payload},
509
+ auth_header=f"Bearer {runtime_key}",
510
+ )
511
+
512
+ def send_event(
513
+ self,
514
+ app_id: str,
515
+ *,
516
+ runtime_key: str,
517
+ workflow_id: Optional[str],
518
+ event_type: str,
519
+ channel: str,
520
+ source: str,
521
+ message: str,
522
+ payload: dict[str, Any],
523
+ metadata: dict[str, Any],
524
+ idempotency_key: Optional[str] = None,
525
+ ) -> dict[str, Any]:
526
+ headers: dict[str, str] = {}
527
+ if idempotency_key:
528
+ headers["X-Idempotency-Key"] = idempotency_key
529
+ return self._request(
530
+ f"/v1/apps/{app_id}/events",
531
+ method="POST",
532
+ headers=headers,
533
+ body={
534
+ "workflow_id": workflow_id,
535
+ "event_type": event_type,
536
+ "channel": channel,
537
+ "source": source,
538
+ "message": message,
539
+ "payload": payload,
540
+ "metadata": metadata,
541
+ },
542
+ auth_header=f"Bearer {runtime_key}",
543
+ )
544
+
545
+ def setup(self, app_id: str) -> dict[str, Any]:
546
+ return self._request(f"/apps/{app_id}/setup")
547
+
548
+ def invite(self, app_id: str, *, email: str, role: str, expires_in_hours: int) -> dict[str, Any]:
549
+ return self._request(
550
+ f"/apps/{app_id}/invites",
551
+ method="POST",
552
+ body={"email": email, "role": role, "expires_in_hours": int(expires_in_hours)},
553
+ )
554
+
555
+
556
+ class AraClient:
557
+ """Runtime client bound to one App manifest."""
558
+
559
+ def __init__(self, *, manifest: dict[str, Any], api_base_url: str, access_token: str, cwd: pathlib.Path):
560
+ self.manifest = dict(manifest)
561
+ self.cwd = cwd
562
+ self.http = _Http(api_base_url, access_token)
563
+
564
+ @classmethod
565
+ def from_env(cls, *, manifest: dict[str, Any], cwd: Optional[str] = None) -> "AraClient":
566
+ base = pathlib.Path(cwd or os.getcwd())
567
+ _read_dotenv(base / ".env")
568
+ env = _require_env("ARA_API_BASE_URL", "ARA_ACCESS_TOKEN")
569
+ return cls(
570
+ manifest=manifest,
571
+ api_base_url=env["ARA_API_BASE_URL"],
572
+ access_token=env["ARA_ACCESS_TOKEN"],
573
+ cwd=base,
574
+ )
575
+
576
+ def _find_app_by_slug(self) -> Optional[dict[str, Any]]:
577
+ rows = self.http.list_apps().get("apps") or []
578
+ for row in rows:
579
+ if str(row.get("slug") or "") != str(self.manifest.get("slug") or ""):
580
+ continue
581
+ if str(row.get("role") or "") == "owner":
582
+ return row
583
+ return None
584
+
585
+ def _resolve_runtime_key(self, explicit: Optional[str] = None) -> str:
586
+ if explicit:
587
+ return explicit
588
+ env_key = os.getenv("ARA_RUNTIME_KEY", "").strip()
589
+ if env_key:
590
+ return env_key
591
+ path = self.cwd / ".runtime-key.local"
592
+ if path.exists():
593
+ return path.read_text(encoding="utf-8").strip()
594
+ return ""
595
+
596
+ def deploy(
597
+ self,
598
+ *,
599
+ activate: bool = True,
600
+ key_name: Optional[str] = None,
601
+ key_rpm: int = 60,
602
+ warm: bool = False,
603
+ warm_workflow_id: Optional[str] = None,
604
+ on_existing: Optional[str] = None,
605
+ ) -> dict[str, Any]:
606
+ if on_existing not in (None, "update", "error"):
607
+ raise ValueError("on_existing must be one of: update, error")
608
+
609
+ existing = self._find_app_by_slug()
610
+ app_id = str(existing.get("id")) if existing else ""
611
+ if app_id and on_existing == "error":
612
+ raise RuntimeError(
613
+ f"Project '{self.manifest.get('slug')}' already exists for this account (app_id={app_id})."
614
+ )
615
+
616
+ payload = {
617
+ "name": self.manifest.get("name"),
618
+ "description": self.manifest.get("description") or "",
619
+ "agent": self.manifest.get("agent") or {},
620
+ "workflows": self.manifest.get("workflows") or [],
621
+ "interfaces": self.manifest.get("interfaces") or {},
622
+ "runtime_profile": self.manifest.get("runtime_profile") or {},
623
+ }
624
+
625
+ if app_id:
626
+ if activate:
627
+ payload["status"] = "active"
628
+ self.http.update_app(app_id, payload)
629
+ else:
630
+ created = self.http.create_app({**payload, "slug": self.manifest.get("slug")})
631
+ app_id = str((created.get("app") or {}).get("id") or "")
632
+ if not app_id:
633
+ raise RuntimeError("deploy failed: missing app id")
634
+ if activate:
635
+ self.http.update_app(app_id, {"status": "active"})
636
+
637
+ key_out = self.http.create_key(
638
+ app_id,
639
+ name=(key_name or f"{self.manifest.get('slug')}-py-local"),
640
+ requests_per_minute=int(key_rpm),
641
+ )
642
+ runtime_key = str(key_out.get("key") or "").strip()
643
+ if not runtime_key:
644
+ raise RuntimeError("deploy failed: runtime key missing")
645
+ key_path = self.cwd / ".runtime-key.local"
646
+ key_path.write_text(runtime_key + "\n", encoding="utf-8")
647
+
648
+ warmup = None
649
+ if warm:
650
+ warmup = self.http.run_app(
651
+ app_id,
652
+ runtime_key=runtime_key,
653
+ workflow_id=warm_workflow_id,
654
+ input_payload={},
655
+ warmup=True,
656
+ )
657
+
658
+ return {
659
+ "app_id": app_id,
660
+ "slug": self.manifest.get("slug"),
661
+ "runtime_key_written": True,
662
+ "runtime_key_path": str(key_path),
663
+ "warmup": warmup,
664
+ }
665
+
666
+ def run(self, *, workflow_id: Optional[str], input_payload: Optional[dict[str, Any]] = None, runtime_key: Optional[str] = None):
667
+ app = self._find_app_by_slug()
668
+ if not app:
669
+ raise RuntimeError(f"App '{self.manifest.get('slug')}' not found. Deploy first.")
670
+ key = self._resolve_runtime_key(runtime_key)
671
+ if not key:
672
+ raise RuntimeError("Missing runtime key. Set ARA_RUNTIME_KEY or run deploy first.")
673
+ return self.http.run_app(str(app["id"]), runtime_key=key, workflow_id=workflow_id, input_payload=input_payload or {})
674
+
675
+ def events(
676
+ self,
677
+ *,
678
+ workflow_id: Optional[str],
679
+ event_type: str,
680
+ channel: str,
681
+ source: str,
682
+ message: str,
683
+ payload: Optional[dict[str, Any]] = None,
684
+ metadata: Optional[dict[str, Any]] = None,
685
+ idempotency_key: Optional[str] = None,
686
+ runtime_key: Optional[str] = None,
687
+ ) -> dict[str, Any]:
688
+ app = self._find_app_by_slug()
689
+ if not app:
690
+ raise RuntimeError(f"App '{self.manifest.get('slug')}' not found. Deploy first.")
691
+ key = self._resolve_runtime_key(runtime_key)
692
+ if not key:
693
+ raise RuntimeError("Missing runtime key. Set ARA_RUNTIME_KEY or run deploy first.")
694
+ return self.http.send_event(
695
+ str(app["id"]),
696
+ runtime_key=key,
697
+ workflow_id=workflow_id,
698
+ event_type=event_type,
699
+ channel=channel,
700
+ source=source,
701
+ message=message,
702
+ payload=payload or {},
703
+ metadata=metadata or {},
704
+ idempotency_key=idempotency_key,
705
+ )
706
+
707
+ def setup(self) -> dict[str, Any]:
708
+ app = self._find_app_by_slug()
709
+ if not app:
710
+ raise RuntimeError(f"App '{self.manifest.get('slug')}' not found. Deploy first.")
711
+ return self.http.setup(str(app["id"]))
712
+
713
+ def invite(self, *, email: str, role: str = "viewer", expires_in_hours: int = 24 * 7) -> dict[str, Any]:
714
+ app = self._find_app_by_slug()
715
+ if not app:
716
+ raise RuntimeError(f"App '{self.manifest.get('slug')}' not found. Deploy first.")
717
+ return self.http.invite(str(app["id"]), email=email, role=role, expires_in_hours=expires_in_hours)
718
+
719
+
720
+ def _parse_pairs(items: list[str]) -> dict[str, str]:
721
+ out: dict[str, str] = {}
722
+ for item in items:
723
+ if "=" not in item:
724
+ continue
725
+ key, value = item.split("=", 1)
726
+ key = key.strip()
727
+ if key:
728
+ out[key] = value
729
+ return out
730
+
731
+
732
+ def run_cli(app: App | dict[str, Any], argv: Optional[list[str]] = None, *, default_command: str = "deploy") -> None:
733
+ app_obj = app if isinstance(app, App) else None
734
+ manifest = app_obj.manifest if app_obj is not None else dict(app)
735
+
736
+ parser = argparse.ArgumentParser(description="Ara Python SDK CLI")
737
+ sub = parser.add_subparsers(dest="command")
738
+
739
+ p_deploy = sub.add_parser("deploy")
740
+ p_deploy.add_argument("--activate", default="true")
741
+ p_deploy.add_argument("--key-name", default="")
742
+ p_deploy.add_argument("--rpm", type=int, default=60)
743
+ p_deploy.add_argument("--warm", default="false")
744
+ p_deploy.add_argument("--warm-workflow", default="")
745
+ p_deploy.add_argument("--on-existing", choices=["update", "error"])
746
+
747
+ p_run = sub.add_parser("run")
748
+ p_run.add_argument("--workflow", default="")
749
+ p_run.add_argument("--message", default="")
750
+ p_run.add_argument("--input", action="append", default=[])
751
+
752
+ p_events = sub.add_parser("events")
753
+ p_events.add_argument("--workflow", default="")
754
+ p_events.add_argument("--event-type", default="webhook.message.received")
755
+ p_events.add_argument("--channel", default="webhook")
756
+ p_events.add_argument("--source", default="webhook")
757
+ p_events.add_argument("--message", default="")
758
+ p_events.add_argument("--input", action="append", default=[])
759
+ p_events.add_argument("--metadata", action="append", default=[])
760
+ p_events.add_argument("--idempotency-key", default="")
761
+
762
+ p_invite = sub.add_parser("invite")
763
+ p_invite.add_argument("--email", default="")
764
+ p_invite.add_argument("--role", default="viewer")
765
+ p_invite.add_argument("--expires-hours", type=int, default=24 * 7)
766
+
767
+ p_local = sub.add_parser("local")
768
+ p_local.add_argument("--input", action="append", default=[])
769
+
770
+ sub.add_parser("setup")
771
+
772
+ args = parser.parse_args(argv)
773
+ command = args.command or default_command
774
+ client = AraClient.from_env(manifest=manifest, cwd=os.getcwd())
775
+
776
+ if command == "deploy":
777
+ deploy_kwargs: dict[str, Any] = {
778
+ "activate": str(args.activate).lower() != "false",
779
+ "key_name": args.key_name or None,
780
+ "key_rpm": int(args.rpm),
781
+ "warm": str(args.warm).lower() == "true",
782
+ "warm_workflow_id": args.warm_workflow or None,
783
+ }
784
+ if args.on_existing:
785
+ deploy_kwargs["on_existing"] = args.on_existing
786
+ print(json.dumps(client.deploy(**deploy_kwargs), indent=2))
787
+ return
788
+
789
+ if command == "run":
790
+ payload = _parse_pairs(args.input)
791
+ if args.message:
792
+ payload["message"] = args.message
793
+ run_id = str(payload.get("run_id") or "").strip() or _new_run_id()
794
+ payload.setdefault("run_id", run_id)
795
+ payload.setdefault("idempotency_key", f"{_slugify(args.workflow or 'default')}-{_slugify(run_id)}")
796
+ print(json.dumps(client.run(workflow_id=args.workflow or None, input_payload=payload), indent=2))
797
+ return
798
+
799
+ if command == "events":
800
+ payload = _parse_pairs(args.input)
801
+ metadata = _parse_pairs(args.metadata)
802
+ idem = str(args.idempotency_key or "").strip() or f"{_slugify(args.event_type)}-{_slugify(_new_run_id())}"
803
+ print(
804
+ json.dumps(
805
+ client.events(
806
+ workflow_id=args.workflow or None,
807
+ event_type=args.event_type,
808
+ channel=args.channel,
809
+ source=args.source,
810
+ message=args.message,
811
+ payload=payload,
812
+ metadata=metadata,
813
+ idempotency_key=idem,
814
+ ),
815
+ indent=2,
816
+ )
817
+ )
818
+ return
819
+
820
+ if command == "invite":
821
+ email = str(args.email or "").strip()
822
+ if not email:
823
+ raise RuntimeError("invite requires --email")
824
+ print(json.dumps(client.invite(email=email, role=args.role, expires_in_hours=args.expires_hours), indent=2))
825
+ return
826
+
827
+ if command == "local":
828
+ if app_obj is None:
829
+ raise RuntimeError("local command requires an App(...) instance")
830
+ print(json.dumps({"ok": True, "result": app_obj.call_local_entrypoint(_parse_pairs(args.input))}, indent=2))
831
+ return
832
+
833
+ if command == "setup":
834
+ print(json.dumps(client.setup(), indent=2))
835
+ return
836
+
837
+ parser.print_help()
@@ -0,0 +1,87 @@
1
+ import io
2
+ import urllib.error
3
+
4
+ import pytest
5
+
6
+ from ara_sdk import App, cron, runtime, sandbox
7
+ from ara_sdk import core
8
+
9
+
10
+ def test_app_manifest_project_name_slug_priority():
11
+ app = App(name="Investor Booker", project_name="Team Internal App")
12
+ assert app.slug == "team-internal-app"
13
+
14
+
15
+ def test_subagent_registers_profile_and_workflow():
16
+ app = App(name="Test App")
17
+
18
+ @app.subagent(
19
+ id="booking-coordinator",
20
+ workflow_id="booking-coordinator",
21
+ instructions="Coordinate booking tasks.",
22
+ schedule=cron("0 10 * * 1-5"),
23
+ runtime=runtime(memory_mb=1024),
24
+ sandbox=sandbox(max_concurrency=3),
25
+ )
26
+ def booking():
27
+ """Coordinate booking workflows."""
28
+
29
+ manifest = app.manifest
30
+ profiles = manifest["agent"]["profiles"]
31
+ workflows = manifest["workflows"]
32
+ subagents = manifest["agent"]["subagents"]
33
+
34
+ assert profiles[0]["id"] == "booking-coordinator"
35
+ assert workflows[0]["id"] == "booking-coordinator"
36
+ assert workflows[0]["trigger"]["type"] == "cron"
37
+ assert subagents[0]["sandbox"]["max_concurrency"] == 3
38
+
39
+
40
+ def test_http_error_redacts_response_body_by_default(monkeypatch):
41
+ leaked = "internal stack trace: host=prod-worker-17"
42
+
43
+ def _raise_http_error(*args, **kwargs):
44
+ raise urllib.error.HTTPError(
45
+ url="https://api.ara.so/apps",
46
+ code=500,
47
+ msg="Internal Server Error",
48
+ hdrs=None,
49
+ fp=io.BytesIO(leaked.encode("utf-8")),
50
+ )
51
+
52
+ monkeypatch.delenv("ARA_SDK_DEBUG_HTTP_ERRORS", raising=False)
53
+ monkeypatch.setattr(core.urllib.request, "urlopen", _raise_http_error)
54
+
55
+ http = core._Http(base_url="https://api.ara.so", access_token="test-token")
56
+ with pytest.raises(RuntimeError) as exc:
57
+ http.list_apps()
58
+
59
+ message = str(exc.value)
60
+ assert "GET /apps failed (500)." in message
61
+ assert "Response body hidden by default" in message
62
+ assert leaked not in message
63
+
64
+
65
+ def test_http_error_includes_response_body_in_debug_mode(monkeypatch):
66
+ details = '{"error":"upstream timeout"}'
67
+
68
+ def _raise_http_error(*args, **kwargs):
69
+ raise urllib.error.HTTPError(
70
+ url="https://api.ara.so/apps",
71
+ code=504,
72
+ msg="Gateway Timeout",
73
+ hdrs=None,
74
+ fp=io.BytesIO(details.encode("utf-8")),
75
+ )
76
+
77
+ monkeypatch.setenv("ARA_SDK_DEBUG_HTTP_ERRORS", "true")
78
+ monkeypatch.setattr(core.urllib.request, "urlopen", _raise_http_error)
79
+
80
+ http = core._Http(base_url="https://api.ara.so", access_token="test-token")
81
+ with pytest.raises(RuntimeError) as exc:
82
+ http.list_apps()
83
+
84
+ message = str(exc.value)
85
+ assert "GET /apps failed (504):" in message
86
+ assert details in message
87
+