Metadata-Version: 2.4
Name: agentgraft
Version: 0.0.1
Summary: Python SDK for AgentGraft — define your chat config in code, sync to the hosted service.
Author: AgentGraft
License-Expression: MIT
Project-URL: Homepage, https://agentgraft.com
Keywords: agentgraft,chat,llm,agent,django,widget
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: django
Dynamic: license-file

# agentgraft

Python SDK for [AgentGraft](https://agentgraft.com). Define your chat
configuration in code, sync it to the hosted service, and embed a chat
widget on your site with one template tag.

```bash
pip install agentgraft
```

The PyPI distribution and the import name are both `agentgraft`. Django
glue lives at `agentgraft.django`; everything else is framework-agnostic.

---

## How it fits together

Three actors:

- **End user** — talks to the widget in their browser.
- **Host app** — your Django project. Owns user identity, owns the data,
  owns tool execution.
- **AgentGraft** — hosted service. Owns the chat UI, the LLM loop, and
  the conversation storage.

The SDK is the bridge. You write one Python file declaring your tools,
agents, and prompt context. You run one command to push that file to
AgentGraft. You drop one template tag wherever the chat should appear.
When the LLM wants to call a tool, AgentGraft signs an HTTP request to
your host app's webhook URL; the SDK receives it, verifies the
signature, runs your function, returns the result.

The host app's database is **never read** by AgentGraft directly.
AgentGraft only ever sees tool *schemas* — never tool implementations.

---

## Quickstart (Django)

### 1. Install

```bash
pip install agentgraft
```

### 2. Add to `INSTALLED_APPS`

```python
# settings.py
INSTALLED_APPS = [
    # ...
    "agentgraft.django",
]
```

### 3. Configure

```python
# settings.py
AGENTGRAFT = {
    "config_module": "myapp.agentgraft_config",
    "profiles": {
        "default": {
            "api_base":       "https://agentgraft.com",
            "api_key":        os.environ["AGENTGRAFT_API_KEY"],
            "public_key":     os.environ["AGENTGRAFT_PUBLIC_KEY"],
            "graft_id":       os.environ["AGENTGRAFT_GRAFT_ID"],
            "webhook_secret": os.environ["AGENTGRAFT_WEBHOOK_SECRET"],
        },
    },
}
```

The four credentials come from creating a graft on AgentGraft. The
`api_key` is shown only once at creation — store it as a secret.

### 4. Mount the webhook URL

```python
# urls.py
from django.urls import include, path

urlpatterns = [
    # ...
    path("agentgraft/", include("agentgraft.django.urls")),
]
```

This exposes `POST /agentgraft/webhook/` for tool dispatches. Configure
that URL as your graft's `webhook_url` (either at graft creation or
inside `ag.graft(webhook_url=...)`).

### 5. Write the config

Create `myapp/agentgraft_config.py`:

```python
import agentgraft as ag
from myapp.models import User


@ag.user_loader
def load_user(external_id: str) -> User:
    return User.objects.get(pk=external_id.removeprefix("user:"))


@ag.tool
def list_datasets(user: User) -> list[dict]:
    """List the user's datasets."""
    return list(user.datasets.values("id", "name"))


@ag.tool
def get_dataset(user: User, dataset_id: int) -> dict:
    """Fetch one dataset by id."""
    ds = user.datasets.filter(pk=dataset_id).first()
    return {"id": ds.id, "name": ds.name} if ds else {}


@ag.agent(entry=True, tools=[list_datasets, get_dataset])
def helper():
    """You help users explore their datasets. Be concise."""


ag.graft(
    name="MyApp",
    identity="You are MyApp's assistant.",
    voice="Be direct. Skip filler.",
    policies=[
        ag.policy("agentgraft/no-hallucination"),
        ag.policy("agentgraft/concise"),
    ],
    greeting="Ask me about your datasets.",
    example_prompts=[
        "How many datasets do I have?",
        "Show me the most recent one",
    ],
)
```

### 6. Sync

```bash
python manage.py agentgraft_sync --dry-run   # inspect the payload
python manage.py agentgraft_sync             # push it
```

### 7. Embed the widget

In any Django template:

```django
{% load agentgraft %}
{% agentgraft_widget %}
```

That's the whole integration.

---

## Settings reference

Every key lives under the `AGENTGRAFT` dict in `settings.py`.

```python
AGENTGRAFT = {
    "config_module": "myapp.agentgraft_config",  # optional, default: "agentgraft_config"
    "profiles": {
        "default": {
            "api_base":       "https://agentgraft.com",
            "api_key":        "sk_live_...",
            "public_key":     "pk_live_...",
            "graft_id":       "uuid",
            "webhook_secret": "whsec_...",
            "webhook_url":    "https://myapp.com/agentgraft/webhook/",  # optional
        },
    },
}
```

| Key | Required by | Description |
|---|---|---|
| `config_module` | `AppConfig.ready` | Dotted path to your config module. Defaults to `"agentgraft_config"` (a top-level `agentgraft_config.py`). |
| `profiles.<name>.api_base` | sync, widget | Origin of the AgentGraft service. |
| `profiles.<name>.api_key` | sync | Bearer token for `/api/v1/admin/grafts/{id}/sync/`. Shown once at graft creation. |
| `profiles.<name>.public_key` | widget | Browser-safe key embedded in the widget script tag. |
| `profiles.<name>.graft_id` | sync, widget | UUID of the graft. |
| `profiles.<name>.webhook_secret` | webhook view, widget HMAC | HMAC signing secret used by both AgentGraft (signing tool dispatches) and the host (signing widget identity). |
| `profiles.<name>.webhook_url` | sync (optional) | Where AgentGraft posts tool calls. May also be set via `ag.graft(webhook_url=...)`. Profile config wins if both are set. |

Pull `api_key` and `webhook_secret` from environment variables, never
from a checked-in settings file.

---

## Decorator reference

```python
import agentgraft as ag
```

### `@ag.user_loader`

Resolves an opaque external id back to your user model. Called once per
webhook request before any tool runs.

```python
@ag.user_loader
def load_user(external_id: str) -> User:
    return User.objects.get(pk=external_id.removeprefix("user:"))
```

The widget passes external ids prefixed with `user:` for authenticated
visitors and `session:` for anonymous visitors. Strip the prefix yourself
or branch on it. Optionally accept an `is_anonymous: bool` keyword:

```python
@ag.user_loader
def load_user(external_id: str, *, is_anonymous: bool = False):
    if is_anonymous:
        return {"session_key": external_id.removeprefix("session:")}
    return User.objects.get(pk=external_id.removeprefix("user:"))
```

Raise any exception (e.g. `DoesNotExist`) to fail the lookup — the
webhook view turns it into a `403`. Only one `@ag.user_loader` per
profile.

### `@ag.tool`

Defines a function the LLM can call. The function runs **inside your
host app**, not on AgentGraft's servers.

```python
@ag.tool
def search_assets(user: User, query: str, limit: int = 5) -> list[dict]:
    """Search the catalogue for assets matching the query."""
    return list(user.assets.filter(name__icontains=query)[:limit].values())
```

Rules:

- The first parameter is always `user` and is **never** sent to the LLM.
- Every other parameter must have a Python type hint. The SDK builds a
  JSON schema from those hints — no hint, no decoration.
- The docstring becomes the description the LLM sees, or pass
  `description="..."`.
- A parameter with a default is optional in the schema; without one,
  it's required.
- Tool names must be unique within a profile.

### `@ag.agent`

Defines an agent — a system prompt with an allowed tool set and
optional handoff targets.

```python
@ag.agent(
    entry=True,
    tools=[search_assets, get_asset],
    handoffs=["analyst"],
    model="auto",
)
def concierge():
    """You are MyApp's concierge. Route the user to the right agent."""
```

The function body is **never executed**. Only the docstring matters —
it becomes the agent's instructions. Kwargs:

| Param | Type | Default | Meaning |
|---|---|---|---|
| `entry` | bool | `False` | Mark as the conversation's entry point. At most one per profile. |
| `tools` | list | `[]` | Tool function references or string names. |
| `handoffs` | list | `[]` | Names of agents this one can hand off to. |
| `model` | str | `"auto"` | Model tier hint (`nano` / `mini` / `auto` / `pro`). |

### `ag.graft(...)`

Sets the graft-level prompt context and the widget welcome screen.

```python
ag.graft(
    name="MyApp",
    identity="You work inside MyApp's customer dashboard.",
    product_context="MyApp is a portfolio analytics tool.",
    voice="Be concise. Reach for examples over abstractions.",
    scope="Only answer questions about the user's own portfolio.",
    policies=[
        ag.policy("agentgraft/no-hallucination"),
        ag.policy("agentgraft/cite-sources"),
        "Never give specific buy/sell advice.",
    ],
    webhook_url="https://myapp.com/agentgraft/webhook/",
    greeting="Ask me about your portfolio.",
    example_prompts=[
        "How is my portfolio performing this week?",
        "Which positions are down the most?",
    ],
)
```

The five prompt fields (`identity`, `product_context`, `voice`, `scope`,
`policies`) get composed into every agent's system prompt at runtime.
The two widget fields (`greeting`, `example_prompts`) drive the empty
state visitors see before sending their first message — both fall back
to built-in defaults if blank.

`policies` accepts either a plain string OR a list mixing free-form
strings with `ag.policy(...)` references. References are resolved to
their full body text from the AgentGraft Policy Shop at sync time.

### `ag.policy(codename)`

Reference a curated policy from the AgentGraft Policy Shop. Returns an
opaque sentinel that the sync command resolves into the actual prompt
text by fetching from the public catalogue.

```python
ag.policy("agentgraft/mermaid-strict")
ag.policy("agentgraft/no-hallucination")
ag.policy("agentgraft/secrets-redaction")
```

Browse the catalogue at `https://agentgraft.com/dashboard/policies/`
or fetch the list yourself: `GET /api/v1/policies/`.

---

## Multiple profiles

A single host app can declare multiple chat experiences in one config
file by passing `profile=` to the decorators:

```python
@ag.tool(profile="public")
def what_is_myapp(user) -> str:
    """Return a one-paragraph pitch."""
    return "MyApp is..."

@ag.agent(profile="public", entry=True, tools=[what_is_myapp])
def public_concierge():
    """You greet visitors on the marketing site."""

ag.graft(profile="public", name="MyApp Public", ...)
```

Each profile is an independent registry — a public-profile tool can
never be called against an authenticated-profile user, and vice versa.
Add the profile to settings:

```python
AGENTGRAFT = {
    "profiles": {
        "default": {...},
        "public": {...},
    },
}
```

Sync individual profiles or all at once:

```bash
python manage.py agentgraft_sync                  # default
python manage.py agentgraft_sync --profile public # one
python manage.py agentgraft_sync --all            # every declared profile
```

In templates, name the profile to render:

```django
{% agentgraft_widget %}                    {# default #}
{% agentgraft_widget profile="public" %}   {# named #}
```

---

## Anonymous visitors

Anonymous visitors get persistent conversation history via Django
sessions. The template tag handles this automatically:

- **Authenticated user** → external id is `user:{user.pk}`,
  `is_anonymous=False`.
- **Anonymous visitor** → external id is `session:{session.session_key}`,
  `is_anonymous=True`. The tag forces the session to exist if it
  doesn't yet, so the same browser stays on the same conversation across
  reloads.

Both paths sign the external id with HMAC-SHA256 server-side using the
profile's `webhook_secret`. The widget sends the signature back as
`X-AgentGraft-User-HMAC` on every API call; AgentGraft verifies it and
rejects forged identities.

Your `@ag.user_loader` receives both the external id and the
`is_anonymous` keyword — branch on it to return either a real `User`
row or a guest sentinel.

---

## `manage.py agentgraft_sync`

```bash
python manage.py agentgraft_sync             # push the default profile
python manage.py agentgraft_sync --profile X # push profile X
python manage.py agentgraft_sync --all       # push every declared profile
python manage.py agentgraft_sync --dry-run   # print the payload, don't send
```

What it does:

1. Reads the named profile's settings. Refuses to run if `api_base`,
   `api_key`, or `graft_id` are missing.
2. Reads the registry populated at app startup. Refuses to run if it's
   empty (usually means the config module failed to import).
3. Resolves any `ag.policy(...)` references by fetching from the
   public Policy Shop endpoint.
4. Composes the resolved bodies + free-form strings into a single
   `policies` text block.
5. Builds the sync payload (tools, agents, graft metadata).
6. POSTs to `{api_base}/api/v1/admin/grafts/{graft_id}/sync/` with
   `Authorization: Bearer {api_key}`.
7. Prints a summary of added / updated / removed tools and agents.

Sync is **declarative**: tools and agents that exist on the server but
not in your local config are deleted. Push the entire registry, every
time. The server applies it atomically — either every diff lands or
none do.

---

## The webhook view

Mounted by `path("agentgraft/", include("agentgraft.django.urls"))`.
Exposes:

- `POST /agentgraft/webhook/` — default profile
- `POST /agentgraft/webhook/<profile>/` — named profile

The view:

1. Verifies the `X-AgentGraft-Signature` HMAC against the profile's
   `webhook_secret`. Bad signature → `403`.
2. Parses the JSON body. Bad JSON → `400`.
3. Looks up the tool by name. Unknown tool → `404`.
4. Calls your `user_loader`. Any exception → `403`.
5. Calls the tool function with the user + LLM-supplied arguments.
   Argument mismatch → `400`. Other exceptions → `500`.
6. Returns `{"result": <whatever the tool returned>}` as JSON.

The view is `csrf_exempt` and `require_POST`. You write none of this —
just register your tools and a `user_loader`.

---

## The wire contract

For debugging what AgentGraft sends to your webhook.

**Request body:**

```json
{
  "tool": "list_datasets",
  "arguments": { "limit": 5 },
  "user_external_id": "user:42",
  "is_anonymous": false
}
```

**Headers:**

```
Content-Type: application/json
X-AgentGraft-Signature: <hex sha256 hmac of the raw body, using webhook_secret>
```

**Success response (200):**

```json
{ "result": <whatever the tool returned> }
```

**Error responses:** `{"error": "<message>"}` with one of:

| Code | Meaning |
|---|---|
| 400 | Malformed JSON, missing `tool`, bad arguments |
| 403 | Invalid HMAC, missing signature, or `user_loader` raised |
| 404 | Tool name not in the registry |
| 500 | Tool function raised, or webhook secret not configured |

---

## Type-hint → JSON schema

| Python annotation | JSON schema |
|---|---|
| `str` | `{"type": "string"}` |
| `int` | `{"type": "integer"}` |
| `float` | `{"type": "number"}` |
| `bool` | `{"type": "boolean"}` |
| `list[T]` | `{"type": "array", "items": <T>}` |
| `dict` | `{"type": "object"}` |
| `Optional[T]` / `T \| None` | `<T>`, removed from `required` |

A parameter with a default value is removed from `required` even if
its annotation isn't `Optional`. Missing type hints are a hard error
at decoration time — fail loud, not later in production.

---

## Testing

Tools are plain Python functions. Call them directly with a stub user:

```python
def test_list_datasets():
    user = make_test_user()
    result = list_datasets(user)
    assert all("id" in row for row in result)
```

For tests that exercise the registry, reset between cases:

```python
import pytest
from agentgraft import reset_registry

@pytest.fixture(autouse=True)
def _reset():
    reset_registry()
    yield
    reset_registry()
```

---

## Without Django

The framework-agnostic core (registry, decorators, schema generation,
webhook handler) is importable on its own. The Django adapter is a
thin wrapper around `agentgraft.handle_webhook`, which you can call
from any framework:

```python
from agentgraft import handle_webhook, WebhookError

def my_flask_view():
    try:
        result = handle_webhook(
            body=request.get_data(),
            signature=request.headers.get("X-AgentGraft-Signature", ""),
            profile="default",
        )
    except WebhookError as exc:
        return {"error": exc.message}, exc.status_code
    return result, 200
```

The same pattern works for FastAPI, Starlette, Bottle, raw WSGI. Native
adapters for other frameworks are not yet shipped.

---

## License

MIT. See [LICENSE](./LICENSE) for the full text.
