User City
How product clients read the model directory and call AIService or custom services.
User City is the runtime client for city-facing calls.
Cityis the legacy low-level user client. New code should useEmbassy.userfrom@downcity/federation; this page remains as a migration reference for the old method surface.
It binds one user inside one city:
federation_urlbureau_id, resolved by Federation fromuser_tokenrather than repeated in service action inputuser_token, required for AI service calls and authenticated actions
For guest-access actions such as login, registration, or webhooks, you can pass federation_url only.
You can use it inside browsers, extensions, mobile apps, desktop apps, or your own backend acting on behalf of a user.
Build the right mental model first
The most important point is not the method list. User City unifies three kinds of product-side calls:
- AI service:
city.ai.* - custom service: services you registered in City yourself
- service: services registered into City by services, such as
accounts,usage, orpayment
So from the product side, User City is not only "the thing that calls models." It is the unified user-context entry into the Federation.
Minimal example
import { City } from "@downcity/federation/legacy";
import type { UIMessageChunk } from "ai";
const city = new City({
federation_url: "https://base.example.com",
user_token: "ub_xxx",
});
const catalog = await city.ai.catalog();
const model = catalog.get("deepseek-v4-flash");
if (!model) throw new Error("model not found");
const result = await city.ai.text({
model,
prompt: "Write a welcome message",
});Public Actions
const guest = new City({
federation_url: "https://base.example.com",
});
const login = await guest.service("accounts").action("login/start").invoke({
provider: "email",
bureau_id: "product_id",
});
await guest.service("accounts").action("login/continue").invoke({
login_id: login.login_id,
input: {
email: "[email protected]",
password: "password123",
},
});
const session = await guest.service("accounts").get("login/result", {
login_id: login.login_id,
});After receiving session.user_token, create User City with that token only. Federation verifies the token and resolves bureau_id; ordinary service actions do not repeat it in their input.
Guest calls vs logged-in user calls
It helps to think about User City in two phases:
Guest phase
Pass federation_url only. This is for guest-access Actions such as:
accounts.registeraccounts.login/startaccounts.login/continueaccounts.login/result
Logged-in user phase
Pass federation_url + user_token. This is for:
- AI services
- custom services that require user context
- services that require user context, such as
accounts.me
The most common transition looks like this:
const guest = new City({
federation_url,
});
const login = await guest.service("accounts").action("login/start").invoke({
provider: "email",
bureau_id: "product_id",
});
await guest.service("accounts").action("login/continue").invoke({
login_id: login.login_id,
input: {
email: "[email protected]",
password: "password123",
},
});
const session = await guest.service("accounts").get("login/result", {
login_id: login.login_id,
});
const user = new City({
federation_url,
user_token: session.user_token,
});Why ai.catalog() Comes First
city.ai.catalog() returns a ModelCatalog:
const catalog = await city.ai.catalog();
catalog.get("deepseek-v4-flash");
catalog.all();
catalog.forModality("stream");Recommended usage:
const catalog = await city.ai.catalog();
const model = catalog.get("deepseek-v4-flash");
if (!model) throw new Error("model not found");This keeps raw model IDs from scattering across your product code.
model.price contains AIChannel-supplied pricing display strings. It is for
display only and does not represent the final charge.
The catalog's CityModel is an AI SDK LanguageModelV3 and can be passed directly to Agent or streamText():
const result = streamText({
model,
prompt: "Analyze this problem",
});The call uses Federation's native model pathway. Agent does not convert it into an OpenAI-compatible model.
When a model supports configurable reasoning, the catalog also exposes its available levels:
const model = catalog.get("gpt-5.6-sol");
if (!model) throw new Error("model not found");
const efforts = model?.reasoning?.efforts ?? [];
await city.ai.text({
model,
prompt: "Analyze this problem",
reasoning_effort: "high",
});reasoning_effort must use an ID from the model catalog. When omitted, the model's default_effort or the upstream default applies.
ai.text()
const result = await city.ai.text({
model: catalog.get("deepseek-v4-flash"),
prompt: "Write a welcome message",
});ai.text() returns an AI SDK UIMessage: a complete message that UI code can store and render directly.
The input object is still intentionally open:
modelis required; AIService never chooses a default modelreasoning_effortis optional and must come from the current model's catalog entry- other fields are defined by the AIChannel standard model stream resolved by
AIService - the handler result should be a
UIMessage
If you call a custom service with a non-UIMessage result shape, use city.service(...).action(...).invoke<T>().
ai.stream()
const body = await city.ai.stream({
model: catalog.get("deepseek-v4-flash"),
prompt: "Stream a short paragraph",
});ai.stream() returns an AI SDK UIMessageChunk stream:
const stream: ReadableStream<UIMessageChunk> = await city.ai.stream({
prompt: "Stream a short paragraph",
});It is not the raw HTTP byte stream. The SDK uses CityModel to request the
Federation LanguageModelV3 stream, then converts those model parts into chunk
objects in the SDK.
You can consume it chunk by chunk:
const reader = stream.getReader();
const first = await reader.read();Federation /v1/ai/stream returns standard model events and does not construct
UIMessage output. city.ai.stream() performs that conversion in the SDK.
If you want a single JSON result, use text() instead of stream().
ai.image_create() / ai.image_result() / ai.video()
Image generation is a job API: create a job with image_create(), then poll it with image_result(). When the job succeeds, result is an AI SDK UIMessage. Use file parts inside parts to represent generated image files:
const job = await city.ai.image_create({
prompt: "A fox standing in the snow",
model: catalog.get("image-basic"),
ratio: "1:1",
count: 1,
});
const current = await city.ai.image_result({ job_id: job.job_id });
if (current.status === "succeeded") {
const image = current.result?.parts.find((part) => part.type === "file");
console.log(image?.mediaType, image?.url);
}Generated image file-part url values are returned exactly as the concrete AIChannel supplies them. A Channel may return HTTPS URLs, R2/resource URLs, or data:image/...;base64,... data URLs.
image_create() returns after the task is submitted. AIService schedules a background image/fetch queue task to query upstream status and stores the latest state in the built-in async_jobs table. image_result() only reads that cached state.
You can also use messages for conversational or reference-image workflows:
const job = await city.ai.image_create({
model: "openai-image-basic",
messages: [
{
role: "user",
content: [
{ type: "text", text: "Keep the subject, switch to a white studio background" },
{ type: "image", data_url: "data:image/png;base64,..." },
],
},
],
});The low-level contract is intentionally small: the client sends prompt / messages / model / size / ratio / quality / count / provider_options; the City-side AIChannel creates upstream jobs and implements image_fetch() for upstream status, while AIService owns Queue scheduling and Downcity job storage in async_jobs.
video() still returns AI SDK UIMessage. The City-side AIChannel video action should also return UIMessage.
ai.tts() / ai.asr()
tts() and asr() keep open return types because audio input and output transport shapes vary more across cities:
await city.ai.tts({
text: "Hello",
voice: "alloy",
});If you need a stricter result shape, wrap it in a custom service action.
Service List
city.listServices() returns the registered service summaries for the current City:
const services = await city.listServices();
services[0];
// {
// id: "ai",
// name: "AI",
// env: []
// }This is useful for dynamic menus, debug tooling, or product-side discovery of callable services.
Custom services and official services
From the perspective of User City, both are called the same way.
Custom service
This is a service you registered into City yourself:
const rewritten = await client
.service("rewrite")
.action("formal")
.invoke<{ text: string }>({
prompt: "Rewrite this in a more professional tone",
});Official service
This is a service added into City by an official package:
const me = await city.service("accounts").get("me");
const usage = await city.service("usage").get("me");Payment
If you want product code to think in terms of "payment methods" instead of hand-writing service + action, use:
const methods = await city.payment.methods();
const checkout = await city.payment.method("stripe").invoke({
topup_amount_minor: 500,
idempotency_key: "order_123",
});Here:
city.payment.methods()maps toGET /v1/payment/methodscity.payment.method("stripe").invoke(...)first reads the payment-method definition, then dispatches topayment/checkout/createwithmethod_id: "stripe"
You do not need a second protocol for services. Just remember:
- the source is different
- the calling pattern is the same
- both end up in the unified
/v1/*route space inside City
Common service examples
accounts
const login = await guest.service("accounts").action("login/start").invoke({
provider: "email",
bureau_id: "product_id",
});
await guest.service("accounts").action("login/continue").invoke({
login_id: login.login_id,
input: {
email: "[email protected]",
password: "password123",
},
});
const session = await guest.service("accounts").get("login/result", {
login_id: login.login_id,
});usage
import type {
UserRecentTokenUsageResponse,
UserUsageResponse,
} from "@downcity/services";
const usage = await city.service("usage").get<UserUsageResponse>("me", {
from: "2026-08-01",
to: "2026-08-31",
timezone: "America/Los_Angeles",
});
const recent = await city
.service("usage")
.get<UserRecentTokenUsageResponse>("me/recent", { limit: 20 });
const next_page = recent.next_cursor
? await city.service("usage").get<UserRecentTokenUsageResponse>("me/recent", {
limit: 20,
cursor: recent.next_cursor,
})
: null;mereturns Credits and technical AI usage aggregated by local calendar day.me/recentreturns the current user's latest individual AI Token usage records, with 20 items by default and at most 50.next_cursoris opaque. Anullvalue means there is no next page.- When
metering_statusisunavailable, Token fields arenulland cannot be inferred from Credits. - Both endpoints derive identity only from the current
user_token; query parameters do not acceptuser_idorbureau_id.
payment
const methods = await city.payment.methods();
const checkout = await city.payment.method("stripe").invoke({
topup_amount_minor: 500,
idempotency_key: "order_123",
});When to switch back to AI service
If what you want is model capability itself, prefer:
city.ai.text()city.ai.stream()city.ai.image_create()/city.ai.image_result()
If what you want is a business action, use:
city.service(...).action(...).invoke()city.service(...).get(...)
Custom Services
For your own Service, get a service-scoped invoker and then choose an action:
const result = await client
.service("rewrite")
.action("formal")
.invoke<{ text: string }>({
prompt: "Rewrite this in a more professional tone",
});This is useful when:
- the frontend picks a service from configuration
- you added custom services and do not want to wrap each of them manually
GET Actions
For actions registered with method: "GET", use get() and pass query fields:
const result = await city.service("accounts").get("login/result", {
login_id: "login_xxx",
});Common errors
When User City receives a non-2xx HTTP response, it throws an Error with two extra fields:
status: the HTTP status code.body: the raw response body from City, usually{"error":"..."}.
try {
await city.ai.text({
model: "gpt-5.4",
prompt: "Hello",
});
} catch (error) {
const status = error instanceof Error && "status" in error ? error.status : undefined;
const body = error instanceof Error && "body" in error ? error.body : undefined;
console.log(status, body);
}city.ai.stream() can fail in two stages: when HTTP returns a non-2xx status,
it throws the same status/body error; when HTTP succeeds but the body is empty
or is not a valid CityModel LanguageModelV3 stream, model stream parsing throws
a normal error.
401 / 403
Usually one of these:
user_tokenis missing- the token expired
- the token signature is invalid
- the Token's
bureau_iddoes not belong to the target product
422
Usually one of these:
- the final
query.modelis empty - the request references a model that does not exist
- the current model does not support the requested modality
When not to use User City
Do not use User City for:
- creating cities
- issuing
user_token - modifying runtime env
- maintaining production provider keys
- pausing or re-activating cities
Those are trusted-side actions and belong in FederationAdmin or your own backend.