Reference

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.

City is the legacy low-level user client. New code should use Embassy.user from @downcity/federation; this page remains as a migration reference for the old method surface.

It binds one user inside one city:

  • federation_url
  • bureau_id, resolved by Federation from user_token rather than repeated in service action input
  • user_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, or payment

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.register
  • accounts.login/start
  • accounts.login/continue
  • accounts.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:

  • model is required; AIService never chooses a default model
  • reasoning_effort is 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 to GET /v1/payment/methods
  • city.payment.method("stripe").invoke(...) first reads the payment-method definition, then dispatches to payment/checkout/create with method_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;
  • me returns Credits and technical AI usage aggregated by local calendar day.
  • me/recent returns the current user's latest individual AI Token usage records, with 20 items by default and at most 50.
  • next_cursor is opaque. A null value means there is no next page.
  • When metering_status is unavailable, Token fields are null and cannot be inferred from Credits.
  • Both endpoints derive identity only from the current user_token; query parameters do not accept user_id or bureau_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_token is missing
  • the token expired
  • the token signature is invalid
  • the Token's bureau_id does not belong to the target product

422

Usually one of these:

  • the final query.model is 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.