Metadata-Version: 2.4
Name: linkworld-sdk
Version: 1.4.0
Summary: Build apps for the Linkworld Open App Platform
Project-URL: Homepage, https://linkworld.ai
Project-URL: Documentation, https://linkworld.ai/docs
Project-URL: Source, https://github.com/linkworld/linkworld
Project-URL: Issues, https://github.com/linkworld/linkworld/issues
Author-email: Linkworld <hello@linkworld.ai>
License: MIT
License-File: LICENSE
Keywords: agents,ai,linkworld,mcp,platform
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.6
Requires-Dist: pyyaml>=6.0
Requires-Dist: structlog>=24.1
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# linkworld-sdk

Python SDK for building apps on the
[Linkworld](https://linkworld.ai) Open App Platform.

> **Status: 1.1.x.** Production runtime online. Single-agent and
> multi-agent apps can be deployed and run against tenants. M3.11
> adds custom HTTP routes (`@app.http_route`) for OAuth callbacks,
> webhooks, and downloadable artefacts.

## Install

```bash
pip install linkworld-sdk
```

## 60-second tour

```python
from linkworld_sdk import App

app = App.from_manifest("linkworld.app.yaml")

@app.on_inbound
async def handle(ctx, env):
    await ctx.tools.call(
        "email_send",
        to="ops@example.com",
        subject="Inbound!",
        body=f"From {env.user_id}: {env.message_text}",
    )

if __name__ == "__main__":
    app.run()
```

The matching manifest:

```yaml
apiVersion: linkworld.ai/v2
app_id: my-app
version: 0.1.0
name: My App
required_scopes: [mail.send]
runtime:
  image: ghcr.io/your-org/my-app:0.1.0
lifecycle:
  on_inbound: true
```

## Local development

```bash
LINKWORLD_LOCAL=1 python main.py
```

This starts a trigger HTTP server you can `curl` to simulate any
platform event. Tools are mocked — `ctx.tools.call(...)` records
the call and returns whatever you wired with `MockTools`.

See [`examples/hello-world/`](./examples/hello-world/) for a runnable
walkthrough.

## Testing your app

```python
import pytest
from linkworld_sdk.testing import TestClient
from my_app import app

@pytest.fixture
def client():
    return TestClient(app)

async def test_echo(client):
    client.tools.set_response("email_send", {"sent": True})
    await client.simulate_inbound(
        tenant_id="t1", user_id="u1", message_text="hello"
    )
    assert client.tools.calls[0][0] == "email_send"
    assert client.tools.calls[0][1]["body"] == "From u1: hello"
```

## Decorator API

| Decorator | Receives | When |
|---|---|---|
| `@app.on_inbound` | `(ctx, env)` | Tenant got an inbound message and opted into fan-out |
| `@app.on_install` | `(ctx)` | Tenant activated the app |
| `@app.on_uninstall` | `(ctx)` | Tenant deactivated the app |
| `@app.on_user_added` | `(ctx, user)` | New user joined the tenant |
| `@app.on_schedule(name)` | `(ctx)` | Cron entry from `lifecycle.schedules[name]` fires |
| `@app.tool(name, ...)` | `(ctx, **args)` | Custom tool exposed to tenant agents |
| `@app.http_route(path, ...)` | `(ctx, request, **path_params)` | Inbound HTTP request — OAuth callbacks, webhooks, downloads |

Every handler receives a `Context`:

```python
ctx.tenant_id            # UUID of the tenant
ctx.user_id              # UUID of the originating user, or None
ctx.app_id               # this app's slug
ctx.event_type           # 'inbound' | 'schedule' | 'install' | 'route' | 'public_route' | …
ctx.tools                # await ctx.tools.call("tool_name", **args)
ctx.secrets              # await ctx.secrets.get("KEY")
ctx.agent                # await ctx.agent.ask("…", agent="drafter")  ← persona-aware LLM
ctx.config               # tenant install_settings answers (read-only)
ctx.platform_origin      # e.g. "https://app.linkworld.ai" (for public_callback_url)
ctx.tenant_slug          # e.g. "ocean-tec"
ctx.public_callback_url("/oauth/callback")  # full URL for THIS tenant's install
ctx.logger               # structlog-compatible logger
```

## ctx.agent.ask — persona-aware LLM (M3.10)

Replaces direct LLM calls. Manifest declares one or more specialist
agents; `ctx.agent.ask` routes to them with the tenant's connected
LLM provider:

```yaml
# manifest
agents:
  - id: drafter
    name: Post Drafter
    is_default: true
    system_prompt: |
      You write LinkedIn posts in the user's voice.
      Always plain text, ≤ 280 chars, no emojis.
  - id: classifier
    name: Comment Classifier
    system_prompt: |
      You classify comments. Output JSON only.
```

```python
@app.tool("draft_post")
async def draft_post(ctx, topic: str) -> dict:
    res = await ctx.agent.ask(
        f"Write a post about {topic}.",
        agent="drafter",  # default agent if omitted
    )
    return {"draft": res.text}

@app.tool("classify_comment")
async def classify_comment(ctx, text: str) -> dict:
    res = await ctx.agent.ask(
        f"Classify: {text}",
        agent="classifier",
        response_format={
            "type": "object",
            "properties": {"sentiment": {"type": "string"}},
        },
    )
    return res.data  # parsed structured output
```

Per-agent `tools_allowed` whitelists are enforced platform-side.
Costs (input + output tokens) are billed to the tenant and tagged
with the app id.

## @app.http_route — custom HTTP endpoints (M3.11)

Exposes raw HTTP endpoints the platform proxies to your container.
Two flavours:

```python
# Public callback — anyone on the internet can hit it. Use for OAuth
# redirects, third-party webhooks, signed downloads.
@app.http_route("/oauth/callback", methods=["GET"], public=True)
async def stripe_callback(ctx, request):
    code = request.query_params.get("code")
    state = request.query_params.get("state")
    # Verify state against ctx.secrets, exchange code for tokens, …
    return {
        "status": 200,
        "body": "<h1>Connected!</h1>",
        "headers": {"content-type": "text/html"},
    }

# Private route — requires a tenant session. Use for download URLs
# you embed in chat replies, partner-driven UIs, etc.
@app.http_route("/quotes/{quote_id}.pdf", methods=["GET"])
async def download_pdf(ctx, request, quote_id: str):
    # ctx.tools and ctx.agent are fully available here.
    pdf_bytes = await render_pdf(ctx, quote_id)
    return {
        "status": 200,
        "body": pdf_bytes,
        "headers": {"content-type": "application/pdf"},
    }
```

Manifest declaration is required:

```yaml
http_routes:
  - path: /oauth/callback
    method: GET
    public: true
  - path: /quotes/{quote_id}.pdf
    method: GET
    public: false
```

**Public route URL:** built per-tenant via `ctx.public_callback_url`,
typically in `on_install` to register with a third-party:

```python
@app.on_install
async def on_install(ctx):
    redirect_uri = ctx.public_callback_url("/oauth/callback")
    # → https://app.linkworld.ai/api/apps/<slug>/public/t/<tenant>/oauth/callback
    await ctx.tools.call("integrations.register", uri=redirect_uri)
```

**Sandbox on public routes:** `ctx.tools` and `ctx.agent` raise on
call — public callbacks have no tenant user, so platform tool calls
have no auth principal. Read `ctx.secrets` for app-scoped values
(OAuth state secrets, signing keys) and return a response.

**Security floor enforced by the platform** (you don't need to
think about it, but you should know it's there):

- 1MB inbound body, 10MB outbound body — anything larger gets 413/502
- Cookie/Authorization headers NEVER forwarded to your container
- Set-Cookie/Location headers stripped from your response (no session
  fixation, no open-redirect via 3xx — 3xx is rewritten to 502)
- Forced `Content-Security-Policy: sandbox` on every response so HTML
  you return can't read tenant cookies even though it's served from
  the platform domain
- Content-Type allowlist: HTML, JSON, plain text, PDF, images. Anything
  else (notably `application/javascript`) → 502
- 30s timeout for public routes, 60s for private; per-(tenant, app)
  inflight cap of 10 concurrent requests — over capacity → 503
- Public M1 = GET only. POST/PUT/DELETE/PATCH on public routes return
  405; mutation surface needs an HMAC-signed token (deferred)
- Path traversal, control chars, protocol-relative paths all rejected

## Manifest schema

The full schema lives at
[`packages/sdk-spec/manifest-v2.schema.yaml`](https://github.com/linkworld/linkworld/blob/main/packages/sdk-spec/manifest-v2.schema.yaml).
Validate locally with `linkworld_sdk.load_manifest("linkworld.app.yaml")`.

## Walled-garden (M3.9)

Partner-app containers run on a locked-down Docker bridge with
**deny-by-default egress**. Direct outbound HTTP to `api.linkedin.com`,
`api.anthropic.com`, or any other public host is dropped at the
firewall layer — your code can only reach the platform's MCP relay.

To talk to an external service, use a platform-mediated tool:

| Need | Tool | Scope |
|---|---|---|
| LLM (text + vision) | `ctx.agent.ask("…", images=[...])` | implicit via app's agent declaration |
| LinkedIn post / comment | `linkedin_*` | `linkedin.read` / `linkedin.write` |
| Email (M365) | `email_send`, `email_search` | `mail.send`, `mail.read` |
| WhatsApp | `whatsapp_send` | `whatsapp.send` |
| Documents | `create_pdf`, `render_document` | `document.render` |

> Note: `llm_complete` / `llm_vision` were removed in M3.10. All LLM
> traffic now goes through `ctx.agent.ask`, which respects the
> declared agent's system prompt and per-agent tool allowlist.

The platform resolves the tenant's stored credentials, makes the
call, and bills the tenant — your app never sees an API key.

The legacy `network.egress_hosts` manifest field is **deprecated**
and accepted for back-compat but ignored at runtime. New manifests
should drop it; reach external services via tools instead.

## License

MIT — see [LICENSE](./LICENSE).
