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.
It binds one user inside one city:
federation_urlcity_id, automatically injected into AI service and user action callsuser_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:
client.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/city";
import type { UIMessageChunk } from "ai";
const client = new City({
role: "user",
federation_url: "https://base.example.com",
city_id: "city_xxx",
user_token: "ub_xxx",
});
const catalog = await client.ai.listModels();
const result = await client.ai.text({
model: catalog.default(),
prompt: "Write a welcome message",
});Public Actions
const guest = new City({
role: "user",
federation_url: "https://base.example.com",
});
const session = await guest.service("accounts").action("login").invoke<{
user_token: string;
user_id?: string;
email?: string;
}>({
email: "[email protected]",
password: "password123",
city_id: "city_xxx",
});After receiving session.user_token, create a User City with city_id + user_token for AI service calls.
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.loginaccounts.oauth/startaccounts.oauth/result
Logged-in user phase
Pass federation_url + city_id + 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({
role: "user",
federation_url,
});
const session = await guest.service("accounts").action("login").invoke<{
user_token: string;
}>({
email: "[email protected]",
password: "password123",
city_id: "city_xxx",
});
const user = new City({
role: "user",
federation_url,
city_id: "city_xxx",
user_token: session.user_token,
});Why ai.listModels() Comes First
client.ai.listModels() returns a ModelCatalog:
const catalog = await client.ai.listModels();
catalog.get("deepseek-v4-flash");
catalog.default();
catalog.all();
catalog.forModality("stream");Recommended usage:
const catalog = await client.ai.listModels();
const model = catalog.get("deepseek-v4-flash") ?? catalog.default();This keeps raw model IDs from scattering across your city code.
ai.text()
const result = await client.ai.text({
model: catalog.default(),
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 optional- other fields are defined by the Provider
textaction resolved byAIService - the handler result should be a
UIMessage
If no model is provided, AIService uses the default model for the current modality.
If you call a custom service with a non-UIMessage result shape, use client.service(...).action(...).invoke<T>().
ai.stream()
const body = await client.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 client.ai.stream({
prompt: "Stream a short paragraph",
});It is not the raw HTTP byte stream. The SDK parses the AI SDK UIMessage SSE body returned by City into chunk objects.
You can consume it chunk by chunk:
const reader = stream.getReader();
const first = await reader.read();The City-side stream handler should return the result of AI SDK createUIMessageStreamResponse() or streamText().toUIMessageStreamResponse().
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 client.ai.image_create({
prompt: "A fox standing in the snow",
model: catalog.get("image-basic"),
ratio: "1:1",
count: 1,
});
const current = await client.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 Provider supplies them. A Provider 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 client.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 Provider 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 Provider 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 client.ai.tts({
text: "Hello",
voice: "alloy",
});If you need a stricter result shape, wrap it in a custom service action.
Service List
client.listServices() returns the registered service summaries for the current City:
const services = await client.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 client.service("accounts").get("me");
const usage = await client.service("usage").get("me");Payment
If you want the city code to think in terms of "payment methods" instead of hand-writing service + action, use:
const methods = await client.payment.methods();
const checkout = await client.payment.method("stripe").invoke({
topup_id: "topup_demo",
});Here:
client.payment.methods()maps toGET /v1/payment/methodsclient.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 session = await guest.service("accounts").action("login").invoke({
email: "[email protected]",
password: "password123",
city_id: "city_xxx",
});usage
const usage = await client.service("usage").get("me");payment
const methods = await client.payment.methods();
const checkout = await client.payment.method("stripe").invoke({
topup_id: "topup_demo",
});When to switch back to AI service
If what you want is model capability itself, prefer:
client.ai.text()client.ai.stream()client.ai.image_create()/client.ai.image_result()
If what you want is a business action, use:
client.service(...).action(...).invoke()client.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 client.service("accounts").get("oauth/result", {
state: "oauth_state_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 client.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);
}client.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 an AI SDK UIMessage stream, the stream parser throws a normal parsing error.
401 / 403
Usually one of these:
user_tokenis missing- the token expired
- the token signature is invalid
- the request
city_iddoes not match the city bound to the token
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 Admin City or your own backend.