Skip to main content

Guests and customers

Who the agent is talking to​

A customer is one person your agent talks to. Parlant keeps one record per person, so they are recognized across conversations. Your agent uses the record to know things like their plan or open orders. Your code uses it to act for them, and rules can apply only to some groups of customers. Test customers, called personas, keep tests away from real people.

What you'll learn
  • Two places for customer facts: variables for the agent, metadata for your code
  • What a customer record holds, and why the guest is a real customer
  • How personas keep test runs away from real people
  • How to read, update, and delete customers, and scope rules to a group
Talk this page through with your coding agent

Every conversation your agent has is with someone. Every session belongs to a customer, your tools read the current customer to act on their behalf, and you can scope rules to groups of customers.

This page starts with the part you will use most: the information your agent knows about a specific customer. Then it looks at the record itself, where it comes from, and how you manage it over time.

Customer-specific information​

An agent that greets a customer by name, knows their plan, and sees their open orders makes the customer feel attended to. Parlant gives you two places to keep that kind of information, and they serve different readers.

Variables are for the agent. Parlant loads a variable's value at the start of every turn and renders it into the prompt, so the model reads it the way it reads the conversation.

Metadata is for your code. It is a set of string key-value pairs on the customer record that your tools and hooks read and write. The model never sees them.

Variables​

Suppose your customers have different subscription plans. Your agent needs the plan that belongs to the person asking, not one plan description shared by everyone.

To do that, create a variable on the agent, then set its value for a customer, a group, the agent as a whole, or globally. On each turn, Parlant resolves the value from the most specific tier that has one: customer first, then the customer's groups, then agent, then global.

In the example below, vip is a group (a named set of customers created with server.create_group), and dana is a customer record. Both are covered further down the page.

import parlant.sdk as p

plan = await agent.create_variable(
name="subscription_plan",
description="The customer's current plan and what it includes",
)

await plan.set_global_value("Free plan: 3 projects, community support") # the fallback
await plan.set_value_for_tag(vip.id, "Enterprise plan: unlimited projects, dedicated support") # a group
await plan.set_value_for_customer(dana, "Pro plan: 20 projects, email support, renews on the 14th") # one customer

A policy can then say "when the customer asks what their plan includes, answer from their subscription plan", and your agent has the value right in front of it, with no tool call. Values are JSON-serializable, so a small object works as well as a string.

Set values from your backend whenever the underlying fact changes. A billing webhook, for example, updates the customer's plan variable the same way it moves them into the VIP group.

Tool-enabled variables. Sometimes the value lives in another system. In that case, give the variable a tool and Parlant fetches it for you.

A tool is a Python function marked with @p.tool. Inside it, p.Customer.current is the customer the session belongs to:

@p.tool
async def fetch_open_orders() -> p.ToolResult:
orders = await store.open_orders(p.Customer.current.id)
return p.ToolResult(data=[{"id": o.id, "status": o.status} for o in orders])


await agent.create_variable(
name="open_orders",
description="The customer's orders that haven't been delivered yet",
tool=fetch_open_orders,
freshness_rules="*/15 * * * *", # a cron expression: refresh at most every fifteen minutes
)

The tool takes no arguments and runs with the session's customer in context. Without freshness_rules, it runs before every reply. With a cron expression, it runs only when the stored value is older than the schedule allows. Here, that means at most every fifteen minutes per customer.

Note that tool-backed values should stay small, because the value goes into every prompt. Parlant warns you when one exceeds about a thousand tokens. For large or one-off data, use a tool the agent calls when it needs to.

Reading variables from code. Inside a turn, await variable.get_value() returns the value resolved for the current customer, refreshing a tool-backed one if it is stale. A retriever receives all resolved values as ctx.variables. Outside a turn, use the tier-specific getters, such as get_value_for_customer(customer).

Metadata​

Now that your agent knows what it needs about a customer, let's turn to what your code needs. Metadata is the customer record's own key-value store, with string values. It is the place for facts your code acts on rather than the model: an external id, a locale, a feature flag, the date of the last handoff.

@p.tool
async def get_account_summary() -> p.ToolResult:
plan = await p.Customer.current.metadata.get("plan")
summary = await billing.summarize(p.Customer.current.id, plan=plan)
return p.ToolResult(data=summary)

metadata.get() is an async read that refreshes from the store; plain metadata["plan"] reads the turn's snapshot. Writes through metadata.set() and metadata.delete() persist immediately. Over HTTP, PATCH /customers/{id} sets and unsets keys, as shown below.

Which one to use. The rule of thumb is simple. If the agent should know it, make it a variable. If your code should act on it, make it metadata. A customer's plan is often both: metadata for the billing tool to branch on, and a variable so the agent can explain it.

Memory that grows from the conversation

For memory that grows from the conversation itself, pair a retriever that reads a store keyed by the customer id with an observation whose on_matched handler writes to it. Retrieving knowledge covers the retriever half, and Cross-session memory builds the whole pattern.

The customer record​

Now that we have seen what your agent and your code can know about a customer, let's look at the record that holds it all. A customer record carries five things:

FieldWhat it holds
idA stable identifier. Prefer the id your own systems already know, such as your CRM or user-database id, so the customer keeps one identity across deployments and integrations.
nameA display name.
metadataString key-value pairs your tools and hooks can read: email, plan, preferred language.
groupsGroup memberships, used to scope rules and filter customers. You define groups in code with server.create_group(name=...) and assign customers to them at runtime.
kindreal or persona. Real is the default. A persona is a test customer; see Personas below.

The guest​

Not every customer is known. One built-in customer always exists, the guest, and it owns every anonymous conversation. Sessions opened without a customer identity belong to it.

The guest is a real customer, not a persona. Guest sessions carry live traffic from people who have not logged in, so Parlant treats the guest's data with the same care as any named customer's.

Note that the guest can't be deleted. Its sessions can be deleted one at a time, or all together with DELETE /sessions?customer_id=guest.

info

Authentication covers how guest identity is issued and verified.

Personas​

A persona is a customer that stands in for a real one while you test. You test against a persona so that a test run never reaches a real person's data, and never fires a tool on their behalf.

Mark a customer as a persona when you create it, with kind=p.CustomerKind.PERSONA:

import parlant.sdk as p

test_customer = await server.create_customer(
id="persona_returns_flow",
name="Dana Levi (test)",
metadata={"email": "[email protected]", "plan": "pro"},
kind=p.CustomerKind.PERSONA,
)

print(test_customer.kind) # CustomerKind.PERSONA

Two rules apply to every persona.

The kind is set once. A persona cannot become a real customer, and a real customer cannot become a persona. There is no kind field on any update, in the SDK or over HTTP. Turning a live customer into a persona is the mistake this feature exists to prevent, so Parlant offers no way to do it.

If you picked the wrong kind, delete the customer and create it again.

Nobody can log in as a persona. A customer JWT whose subject names a persona is rejected with a 401, however the token was made. Only an admin can open a session with a persona, through the "create persona session" permission. Authorization covers permissions.

Checking whether a customer is a persona​

Every way of getting a customer back carries its kind. Read customer.kind, or use customer.is_persona as a short form:

customer = await server.get_customer(id="persona_returns_flow")

if customer.is_persona:
print(f"{customer.name} is safe to test against")

print(customer.kind) # CustomerKind.PERSONA

is_persona is False for anything that is not explicitly a persona, so code that guards on it treats an unfamiliar customer as a real one.

Listing personas​

The HTTP API and the customer store both take a kind filter, so you can separate your test customers from your real ones:

curl "http://localhost:8800/customers?kind=persona" \
-H "Authorization: Bearer $PARLANT_ADMIN_API_KEY"

A listing filtered to persona never includes the guest. A listing filtered to real, and an unfiltered listing, both do.

Where customer records come from​

The purpose of provisioning is to make sure a customer's record exists before it is needed. Parlant gives you four ways to do it.

If you already have a customer database, plug it in directly. That is the recommended way. The other three fit when you would rather let Parlant keep the records for you.

Plug in your own database​

You almost certainly have a users table already. Don't copy it into Parlant. Instead, ask your coding agent to implement the CustomerStore interface over your own database and hand it to the server:

import parlant.sdk as p
from parlant.core.customers import Customer, CustomerId, CustomerStore


class MyCustomerStore(CustomerStore):
"""Serves customers straight from your existing user database."""

def __init__(self, db: MyDatabase) -> None:
self._db = db

async def read_customer(self, customer_id: CustomerId) -> Customer:
user = await self._db.users.get(customer_id)

return Customer(
id=customer_id,
name=user.full_name,
metadata={"plan": user.plan},
groups=[],
creation_utc=user.created_at,
modified_utc=user.updated_at,
)

# ... the remaining CustomerStore methods


async with p.Server(customer_store=MyCustomerStore(db)) as server:
...

This is an outline of the store adapter, not a complete implementation. MyDatabase and db stand for your database client; implement the remaining methods before using this class.

The interface has nine methods: create_customer, read_customer, update_customer, delete_customer, list_customers, upsert_group, remove_group, upsert_metadata, and remove_metadata.

Only read_customer is on the hot path, because Parlant reads the session's customer on every turn. The write methods back the /customers API and p.Customer.current.metadata.set(...), and they may raise if your users table is read-only from Parlant's side.

With this in place, there is no provisioning step and nothing to keep in sync. Parlant reads customers live from your database: a plan change is visible to the very next turn, and deleting the user there deletes them for Parlant too.

Your store decides what a persona is. Customer.kind defaults to real, so a store that never sets it reports every customer as real. That is the safe answer, and it means your own store needs no change. It also means personas only exist in your deployment if your store saves the kind and returns it. Your users table is unlikely to have a column for it, so you will either add one or keep your personas somewhere else.

tip

Overriding engine behavior covers how the store binding works underneath.

How this works with authentication. The store never proves identity. A customer arrives with a JWT (a signed token) that your backend issued for them, and Parlant verifies the token first: signature, audience, expiry. Only then does it ask your store whether the customer id in the token's sub claim exists.

There are three points to get right here.

  • Your tokens and your customers come from the same place, so they can't disagree. The backend that issues login tokens and the store that answers "does this customer exist?" both read the same user database. Any customer who can log in is a customer your store can find, with nothing to sync.
  • Leave auto-provisioning off. It is off by default. Turning it on tells Parlant to create a customer when a valid token names one it doesn't know. With your own store, that means writing into your users table, which it most likely doesn't support and shouldn't. Left off, Parlant rejects a token for a customer your database doesn't know with a 401.
  • The guest has no row in your database. The built-in store answers read_customer(CustomerStore.GUEST_ID) from code, and now that job is yours. Parlant reads the session's customer on every turn, including anonymous ones. Your store must return the guest for the guest id, or every anonymous conversation breaks:
async def read_customer(self, customer_id: CustomerId) -> Customer:
if customer_id == CustomerStore.GUEST_ID:
return Customer(
id=CustomerStore.GUEST_ID,
name="Guest",
metadata={},
groups=[],
creation_utc=GUEST_CREATION_UTC, # any fixed datetime
modified_utc=GUEST_CREATION_UTC,
)

user = await self._db.users.get(customer_id)
...

The three remaining options use Parlant's own customer store. They differ by how the customer first meets your agent.

Auto-provisioning​

If your customers reach Parlant through your app with a JWT your backend created, you don't need to create them at all. Turn on auto-provisioning, and the first valid token from an unknown customer creates the record on the spot, with the id from the token's sub claim and the name from its name claim:

export PARLANT_AUTH_AUTO_PROVISION_CUSTOMERS=true

Or set auto_provision_customers=True in AuthConfig. With this on, your backend never syncs users to Parlant. The customer base builds itself from whoever actually talks to the agent. Auto-provisioned customers are always real, never personas.

info

Authentication has the full token flow.

Pre-provisioning over HTTP​

Sometimes your backend needs a customer to exist before that customer ever opens a chat. The main case is outreach your side starts, say a delivery update or a renewal reminder sent to someone who has never contacted the agent. For that, create the customer explicitly:

POST /customers
Content-Type: application/json

{
"id": "crm-90211",
"name": "Dana Cohen",
"metadata": {"email": "[email protected]", "plan": "pro"},
"groups": ["grp_vip"]
}

The request may also carry "kind": "persona"; without it, the customer is real. Then create the session with customer_id: "crm-90211" and ask the agent to speak first, as shown in Managing chat sessions.

In code, at startup​

For a fixed set of known users, say an internal tool or a demo, create customers in the same code that defines your agent. groups takes Group objects, from server.create_group or server.get_group:

await server.create_customer(
id="crm-90211",
name="Dana Cohen",
metadata={"email": "[email protected]", "plan": "pro"},
groups=[vip],
)

Reading, updating, and deleting​

Once records exist, you will want to read and change them. GET /customers/{id} reads one customer, and GET /customers?limit=25&sort=asc lists them, with the same cursor pagination as sessions. Every customer in a response carries its kind.

Three filters matter in practice. name_prefix is a case-insensitive prefix search for support tools and search-as-you-type: GET /customers?name_prefix=dan finds "Dana Cohen" and "Daniel Roth", and stays fast at large counts.

min_modified_utc returns only customers changed since a timestamp, which is what a sync job that mirrors customers into another system needs. kind returns only real customers or only personas, as shown above.

PATCH /customers/{id} updates the name, and adds or removes metadata keys and group memberships independently. It cannot change the kind:

PATCH /customers/{customer_id}
Content-Type: application/json

{
"metadata": {
"set": {"plan": "enterprise"},
"unset": ["trial_ends"]
},
"groups": {
"add": ["grp_vip"]
}
}

In practice, this call comes from a business event, not from a person editing a record. A customer upgrades their subscription, your billing webhook fires, and it moves the customer into the VIP group. From the next turn, rules that depend on that group's observation become eligible for consideration.

In code, the same membership changes go through the customer's groups accessor. It reads like a list: len, indexing, iteration, and in, which accepts a Group or a group id. It also has async add, remove, and get methods.

create_group or get_group gives you the group you pass by object:

vip = await server.create_group(name="VIP")

customer = await server.get_customer(id="crm-90211")
await customer.groups.add(vip) # idempotent
await customer.groups.remove(vip)

if vip in customer.groups: # membership check
...

add and remove persist right away and refresh the snapshot. await customer.groups.get() refreshes from the store before returning the list.

Scoping rules to a group​

Some instructions only make sense for certain customers. A priority-support offer, for example, should only be considered for accounts entitled to it. The purpose of scoping is to let your stored group membership decide which policies Parlant may consider, before it judges whether they fit the conversation.

Recall that Parlant has two kinds of rule. A policy says what to do in a situation, and an observation only names a situation, so that other things can react to it. A policy that depends on an observation is only considered on turns where the observation holds.

The reliable pattern has two parts: one observation decides membership, and the rules that should only apply to the group depend on it. The point of doing it this way is that membership is decided by exact code, never by the model's judgment.

The observation uses an evaluation_func: a Python function Parlant calls instead of the model to decide whether the rule matches the turn. A group provides one ready-made. includes_current_customer matches on exactly the turns where the session's customer belongs to the group:

vip = await server.create_group(name="VIP")

vip_customer = await agent.create_observation(
condition="The customer is a VIP",
evaluation_func=vip.includes_current_customer,
)

The group also provides excludes_current_customer, the inverse, for a rule that should apply only to customers outside the group.

If you need a check that a group can't express, write the function yourself. It receives a RuleEvaluationContext (which carries the current customer) and returns a RuleEvaluation, with the verdict in is_relevant and a short reasoning stored alongside it:

async def is_vip_customer(
context: p.RuleEvaluationContext,
observation: p.Observation,
) -> p.RuleEvaluation:
is_member = vip in context.customer.groups # exact code, no model
return p.RuleEvaluation(
id=observation.id,
is_relevant=is_member, # the verdict
reasoning="The customer is in the VIP group"
if is_member
else "The customer is not in the VIP group",
)

Then scope any rule to the group by depending on the observation, with dependencies=[...]:

await agent.create_policy(
content=(
"When the customer reports a problem, offer to connect them "
"to the priority support line"
),
dependencies=[vip_customer],
)

await agent.create_policy(
content=(
"When the customer completes a purchase, mention the yearly "
"VIP anniversary gift"
),
dependencies=[vip_customer],
)

For non-VIP customers, the observation evaluates to false and both dependent policies stay out of the conversation entirely. When the billing webhook above moves a customer into the VIP group, they become eligible from the next turn, with no code change and no restart.

Note that Parlant still checks whether each policy fits the conversation. Membership only decides whether a policy is eligible; it doesn't make the policy apply on its own.

Membership is decided once, in one place. Each dependent policy's content can then focus on instructions for VIP customers, because the observation has already established membership.

Learn more about dependencies

Designing policies covers the dependency pattern in general.

Deleting customers​

Finally, there is deletion. It takes two calls, and together they let your backend automate the right to be forgotten: when a user deletes their account, your deletion flow removes both the customer record and every conversation they ever had:

DELETE /sessions?customer_id=crm-90211
DELETE /customers/crm-90211

Delete the sessions first, then the customer. Note that both are permanent: the sessions' event logs go with them.