Skip to main content

Provisioning customers

When a customer signs up in your application, you create their agent, wire up your tools with that customer's credential, and discover what those tools offer — in one call. The agent is owned by your service user; the customer gets no Delegate account.

One call

POST /v1/provisioning/{tenant_id}/customers
X-Api-Key: <your service user's key>
{
"external_id": "customer_42",
"agent": {
"name": "Customer 42's agent",
"description_prompt": "You act for Customer 42. …"
},
"tools": [
{
"name": "photos",
"credentials": { "token": "the-per-customer-token-you-issued" }
}
]
}

external_id is your own identifier for the customer. Delegate keys the agent to it and uses it to make the call idempotent. There is no customer email or name, because provisioning creates an agent, not a per-customer user.

The response gives you the agent_id and the discovery outcome for each tool.

{
"agent_id": "agt_…",
"external_id": "customer_42",
"agent_created": true,
"tools": [
{ "name": "photos", "attached": true, "credential_set": true,
"discovery": { "tools": ["photos_search_photos"], "rejected": [] } }
]
}

Two things to get right

Store the agent_id against your customer. It's how you'll route that customer's requests to their agent. Everything else — the customer's identity, their data scope — lives in the token you issued, not in Delegate.

The call is idempotent on external_id. Calling it again for the same customer resolves to the existing agent rather than duplicating it — so it is safe to retry, and safe to re-run to attach a new tool or rotate a credential. agent_created comes back false on a resume.

Where the agent's identity goes

description_prompt is the agent's standing instructions — injected into every session. This is where you write who the agent acts for (your person id, their home location, timezone) so a request like "pictures of me in winter" resolves to the right person and hemisphere. It is persistent, so it keeps working no matter how many sessions the customer runs.

Do not put that identity in bootstrap_instructions. Those are a one-time first-session onboarding script, and by default a provisioned agent never runs them:

Provisioned agents skip the first-session bootstrap by default. Bootstrap is an interactive "let's get you set up" turn meant for a human creating an agent in the Delegate UI. A headless per-customer agent has no one to walk it through that, so provisioning creates it with bootstrap already complete — its very first task behaves like any other. Your customer's first message is answered, not treated as setup.

If you do want an onboarding first session, set skip_bootstrap to false and provide bootstrap_instructions:

{
"external_id": "customer_42",
"skip_bootstrap": false,
"agent": {
"name": "Customer 42's agent",
"description_prompt": "You act for Customer 42. …",
"bootstrap_instructions": "On your first session, confirm the photo library is reachable, then stop."
}
}

skip_bootstrap only affects a newly created agent. A resume (same external_id) never changes an existing agent's bootstrap state.

Identity onboarding is off for headless agents too

Delegate's default prompt has a second interactive-setup behaviour: on an interactive session where no identity has been recorded, the agent is told to introduce itself and ask the user their name, role, and company before doing anything else. That's right for a human setting up their own agent in the Delegate UI — and wrong for a headless per-customer agent, which would greet your customer with an interview instead of answering them.

So provisioning also sets skip_identity_onboarding to true by default: the agent skips that block and goes straight to the request. Its identity is already in description_prompt, so nothing is lost. Set it to false if you actually want the agent to gather identity conversationally:

{
"external_id": "customer_42",
"skip_identity_onboarding": false,
"agent": { "name": "Customer 42's agent", "description_prompt": "You act for Customer 42. …" }
}

Like skip_bootstrap, it applies only at creation; a resume never changes it.

The credential is the customer's scope

The credentials.token you send is the per-customer token your MCP server authenticates. It is stored encrypted against that agent and presented on every tool call. This is the link that makes the whole isolation story hold — see the security model. It never appears in a response, a log, or the agent's own context.

The rejected list is your first debugging tool

Each tool's discovery reports what your server offered and what Delegate declined to expose, with reasons. When a customer says "the agent can't find anything", this is almost always where the answer is: a tool name that collided with a built-in, a schema Delegate couldn't read, a server that was unreachable when discovery ran. Re-run discovery any time from POST /v1/agent/{agent_id}/tools/{tool_id}/refresh.

Offboarding

When a customer leaves, delete their agent:

DELETE /v1/agent/{agent_id}

This does more than drop a row. It tears down the agent's sandbox and purges its workspace — the files and images it accumulated — and fails the request (rather than reporting success) if it cannot complete that purge. So a deleted customer's data is genuinely gone, not orphaned in storage. Revoke that customer's key alongside it.

The SDK

The Python SDK wraps all of the above.

from dolly_sdk import DollyClient, AgentSpec, ToolSpec

# The client holds your service user's key — the one account that owns and
# drives every customer's agent.
dolly = DollyClient(api_key="uak_service", base_url="https://agent.example.com")

result = dolly.customers.provision(
tenant_id="tnt_…",
external_id="customer_42",
# instructions is the standing prompt (description_prompt) — the agent's
# identity, injected every session. Bootstrap is skipped by default; pass
# skip_bootstrap=False to opt this agent into an onboarding first session.
agent=AgentSpec(name="Customer 42's agent", instructions="You act for …"),
tools=[ToolSpec(name="photos", credentials={"token": customer_token})],
)

# Store the agent id against your customer; it's how you route their requests.
save_agent(customer_id="customer_42", agent_id=result.agent_id)

Streaming a conversation

Your service user's key drives every agent, so the same key you provisioned with runs the conversation — you just target the right customer's agent_id. The async client streams the agent's progress as it works:

from dolly_sdk import AsyncDollyClient

dolly = AsyncDollyClient(api_key="uak_service", base_url="https://agent.example.com")

agent_id = lookup_agent(customer_id) # the id you stored at provisioning

session_id = None
async for event in dolly.chat.stream(tenant_id, agent_id, "pictures of Sean in winter"):
if event.type == "tool_call":
show_status(f"searching… ({event.tool_name})")
if event.text:
append(event.text)
if event.is_terminal:
session_id = event.session_id # pass back to continue the thread

Build your UI before any of the backend exists using the SDK's fake transport, which replays a scripted conversation through the real client:

from dolly_sdk.testing import fake_chat_transport

transport = fake_chat_transport([
{"type": "tool_call", "data": {"name": "photos_search_photos"}},
{"type": "message", "data": {"content": "Found 3 photos."}},
{"type": "task_completed", "data": {"session_id": "ses_1"}},
])
dolly = AsyncDollyClient("uak_x", "https://agent.test", transport=transport)