Downcity
Downcity City DocsReference

AdminClient

How trusted environments manage studios, issue user tokens, and maintain runtime env.

AdminClient should only run in trusted environments.

Typical cases:

  • your own studio backend
  • local admin scripts
  • internal tools
  • CI or operations scripts

Do not expose admin_secret_key to browsers, public frontends, or uncontrolled clients.

What it owns in the full system

The most accurate mental model is: AdminClient is the trusted-side bridge into City management.

It usually owns three things:

  • managing studio
  • issuing user_token
  • maintaining runtime env
  • running trusted-side balance / redeem_code administration

If UserClient owns "how a user-context studio call reaches City," then AdminClient owns "how the trusted side prepares that call environment."

Minimal example

import { AdminClient } from "@downcity/gate";

const admin = new AdminClient({
  base_url: "https://base.example.com",
  admin_secret_key: process.env.DOWNCITY_CITY_ADMIN_SECRET_KEY,
});

If admin_secret_key is omitted, the SDK tries to read process.env.DOWNCITY_CITY_ADMIN_SECRET_KEY.

Typical call chain

Trusted backendCompletes login, auth, and business checks first.
AdminClientCalls /v1/studios/*, /v1/studios/tokens/apply, /v1/env/*, /v1/ai/models, and /v1/base/instruction.
DowncityVerifies admin_secret_key, then executes studio, token, and env management actions.

studios

list()

const items = await admin.studios.list();

create()

const studio = await admin.studios.create({
  name: "Chrome Extension",
});

The response includes:

  • studio_id
  • name
  • status
  • created_at
  • updated_at

pause() / activate()

await admin.studios.pause(studio.studio_id);

await admin.studios.activate(studio.studio_id);

Useful when you need to:

  • stop all user calls for one studio
  • temporarily take a studio offline
  • reopen calls for a studio later

remove()

await admin.studios.remove(studio.studio_id);

The current behavior is direct studio deletion. Confirm that this matches your business expectation before using it.

tokens.apply()

This is the most common trusted-side action: issue a user_token for one user under one studio.

const issued = await admin.studios.tokens.apply({
  studio_id: studio.studio_id,
  user_id: "user_123",
  metadata: {
    plan: "pro",
    org_id: "org_001",
  },
  ttl: "7d",
});

The response includes:

  • user_token
  • studio_id
  • user_id
  • expires_at

ttl supports:

  • 30m
  • 1h
  • 7d
  • raw seconds
router.post("/login", async (c) => {
  const user_id = await login(c);

  const issued = await admin.studios.tokens.apply({
    studio_id: "studio_xxx",
    user_id,
    ttl: "7d",
  });

  return c.json({
    studio_id: "studio_xxx",
    user_token: issued.user_token,
  });
});

That means:

  1. your backend handles user login first
  2. the trusted backend asks City for user_token
  3. it returns studio_id + user_token to the client
  4. the client calls City through UserClient

Relationship with the accounts service

AdminClient does not replace the accounts service, and the service does not replace AdminClient.

The two common patterns are:

Pattern A: your backend owns login

  • your backend validates user credentials or session
  • your backend uses AdminClient to request user_token
  • the frontend receives studio_id + user_token

Pattern B: the accounts service owns login

  • the frontend uses a guest UserClient to call accounts.login/register
  • the accounts service returns user_token directly
  • the frontend switches to a normal UserClient

So:

  • AdminClient owns trusted-side management actions
  • the accounts service owns the minimal auth capability
  • they are not replacements for each other, but two different login patterns

env

admin.env manages runtime env values. Provider keys written here are stored in the City database and take priority at runtime.

If you need to inspect which provider env keys a code-registered model depends on, read the same model catalog directly through admin.listModels(), then compare it with admin.env.list():

const models = await admin.listModels();

list()

const envs = await admin.env.list();

upsert()

await admin.env.upsert({
  key: "OPENAI_API_KEY",
  value: "sk-xxx",
});

remove()

await admin.env.remove("OPENAI_API_KEY");

import()

await admin.env.import(`
OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://api.openai.com/v1
`);

These changes are written to the env table in the City database. Both business env values and system-level secrets are managed from this City-owned table, so you no longer need to patch Worker or Node host env manually.

listServices() / listModels() / instruction()

Besides studios.* and env.*, the admin side can also read the current capability catalog exposed by City.

listServices()

const services = await admin.listServices();

This returns the registered service list together with each module's declared env requirements.

listModels()

const models = await admin.listModels();

This returns the full model catalog from the admin view. Compared with the user-side model catalog, it also includes:

  • env_requirements
  • default_modes

instruction()

const text = await admin.instruction();
console.log(text);

It maps to GET /v1/base/instruction and returns the aggregated plain-text City instruction document. It is useful for:

  • checking which modules are currently mounted
  • checking which routes each module exposes
  • checking which env keys each module declares
  • feeding runtime guidance into a CLI or agent

AdminClient can also call service-provided admin services

Besides studios.* and env.*, AdminClient can call admin-side services exposed by official packages.

For example:

const users = await admin.service("accounts").get("users");
const sessions = await admin.service("accounts").get("sessions");

const payments = await admin.service("payment.stripe").get("payments");

balance now also exposes a typed invoker for wallet and redeem_code administration:

const issued = await admin.balance.redeemCodes.create({
  amount: 300,
  note: "campaign gift",
});

await admin.balance.redeemCodes.disable({
  redeem_code_id: issued.redeem_code_id,
});

In other words, AdminClient is not only for built-in studios/env endpoints. It is also the trusted-side entry into the same unified service route space.

Error handling

When AdminClient 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 admin.studios.tokens.apply({
    studio_id: "studio_xxx",
    user_id: "user_123",
  });
} 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);
}

Common statuses:

  • 401: admin_secret_key is missing or wrong.
  • 403: the target studio is paused, so no token can be issued.
  • 404: the target studio does not exist.
  • 500: City is missing required config, or the admin action failed internally.

What AdminClient does not manage right now

These do not belong to AdminClient yet:

  • model configuration
  • direct edits to the models table
  • service handler registration
  • direct frontend login interaction
  • browser-side user-context calls

Those belong to:

  • the database layer
  • the City runtime layer

In other words, AdminClient owns env maintenance; runtime model definitions and mounting still belong to the City runtime layer.