Skip to main content

Python SDK for AgentGraft — define your chat config in code, sync to the hosted service.

Project description

agentgraft

Python SDK for AgentGraft. Define your chat configuration in code, sync it to the hosted service, and embed a chat widget on your site with one template tag.

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

pip install agentgraft

2. Add to INSTALLED_APPS

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

3. Configure

# 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

# 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:

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

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:

{% load agentgraft %}
{% agentgraft_widget %}

That's the whole integration.


Settings reference

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

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

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.

@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:

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

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

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

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.

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:

@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:

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

Sync individual profiles or all at once:

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:

{% 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

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:

{
  "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):

{ "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:

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:

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:

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 for the full text.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

agentgraft-0.0.3.tar.gz (41.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

agentgraft-0.0.3-py3-none-any.whl (34.8 kB view details)

Uploaded Python 3

File details

Details for the file agentgraft-0.0.3.tar.gz.

File metadata

  • Download URL: agentgraft-0.0.3.tar.gz
  • Upload date:
  • Size: 41.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.2 {"installer":{"name":"uv","version":"0.10.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for agentgraft-0.0.3.tar.gz
Algorithm Hash digest
SHA256 e2d95888c00b67b8b9d5aec810c7c3ecf7d667cff794003cd6ef71ee36e2257f
MD5 9122ea770be94c85c2d30f217b9d20e0
BLAKE2b-256 c157f84be6c8fbab49267efa9883ee16df16748e0c9f61cfe1a8a58d85f2b0c9

See more details on using hashes here.

File details

Details for the file agentgraft-0.0.3-py3-none-any.whl.

File metadata

  • Download URL: agentgraft-0.0.3-py3-none-any.whl
  • Upload date:
  • Size: 34.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.2 {"installer":{"name":"uv","version":"0.10.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for agentgraft-0.0.3-py3-none-any.whl
Algorithm Hash digest
SHA256 0f40480ea94de3495d88e1d9a739fffdf4676e3860786a5939451b1b0e934796
MD5 20c1f1b14a796a71eadc93eaf223bd4f
BLAKE2b-256 607c99e59d2aef9181e7ad1169915051a221a6a857cd78758b2dbba3d7e19afe

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page