Skip to the content

DocsConcepts

Teams and escalation

Models in named roles, or an ordered list to escalate through.

shape says how many models you get back. Most apps want one (single, the default). team answers models in named roles, and escalation an ordered list to try in turn. A team or an escalation is one counted call; a no_candidate is not counted.

Teams

Pick a strategy preset, give your own roles, or both: your roles override the preset's fields and add roles to it. synthesis=true adds a synthesizer. A purpose whose shape is team, such as debate, is a team without either.

StrategyRoles
debateproposition, opposition (another maker), judges (a third maker)
red-teamblue, red (another maker), white judging both
competitivepeers: 3, each reviewing the others; no judge
hierarchicallead (optimizes quality), workers: 3
expert-panelmembers: 3
collaborativemembers: 3

roles takes name:count pairs (roles=lead:1,workers:4) or, URL-encoded, a JSON object of role name to count, task, needs, min_quality, optimize, distinct_vendor_from, distinct_family and judges, e.g. {"lead":{"optimize":"quality"},"workers":{"count":3}}.

The rules, in the order Rhumb applies them:

  • Each role is filled best-first by its own need: the request's, with the role's task, needs, min_quality and optimize. Roles that judge are filled last.
  • Diversity: a candidate sharing a maker (2) or a model family (1) with members already chosen is ranked after those that don't, so mistakes are less likely to overlap. distinct_vendor_from and distinct_family make it a rule.
  • Judges and synthesizers come from a maker not among the members they judge. Roles named judges, judge, white or synthesizer judge every other role unless you say otherwise.
  • One budget for the whole team. max_price caps the sum of the members' typical calls; budget=cheap or medium caps the sum of their band costs at the band's top times the members. A member is only chosen if the cheapest possible members for the remaining roles still fit, so the mix can be one strong model and several cheap ones.
  • origin_mix: rules such as eu:>=1, cn:<=0 or us:=2. providers_distinct=true puts every member on its own provider.
  • Every member uses its cheapest suitable offering on your providers, chosen when the member is: a model whose cheapest provider an earlier member took can still come on another one.

The answer's team.roles maps each role to its task and members, each a full offering like model; team.typical_call is the whole team's. model is the first role's first member, so a client that reads only model still works. When a role cannot be filled, the no_candidate error names it in role, with the eliminations for that role, team rules included.

team.ts
// Resolve a team: a debate whose three roles come from three different makers,
// under one budget for the whole team. One counted call.
//
//   RHUMB_KEY=nnm_live_... npx tsx team.ts
//
// RHUMB_URL overrides the base URL (default https://rhumbdata.com).
// `roles` also takes "name:count" pairs (roles=lead:1,workers:4) or a JSON
// object of role fields; shape=escalation&length=3 answers an ordered list.

import { pathToFileURL } from "node:url";

type Member = { id: string; provider: string; provider_model_id: string };

type Team = {
  shape: "team";
  team: {
    strategy?: string;
    roles: Record<string, { task: string; members: Member[] }>;
    typical_call?: string;
  };
};

/** Role → its members, or null on any error (use the app's own team then). */
export async function resolveTeam(
  query: Record<string, string>,
): Promise<Team["team"] | null> {
  const key = process.env.RHUMB_KEY;
  if (!key) return null;
  const base = process.env.RHUMB_URL ?? "https://rhumbdata.com";
  const qs = new URLSearchParams({ ...query, shape: "team" }).toString();
  const res = await fetch(`${base}/v1/resolve?${qs}`, {
    headers: { Authorization: `Bearer ${key}` },
    signal: AbortSignal.timeout(2_000),
  }).catch(() => null);
  if (!res?.ok) {
    // A 422 names the role nothing was left for in error.role.
    if (res) console.error("no team:", JSON.stringify(await res.json()));
    return null;
  }
  return ((await res.json()) as Team).team;
}

async function main() {
  const team = await resolveTeam({
    task: "text.reasoning",
    strategy: "debate",
    providers: "gemini,openai,anthropic",
  });
  console.log(
    JSON.stringify(
      team
        ? {
            strategy: team.strategy,
            typical_call: team.typical_call,
            roles: Object.fromEntries(
              Object.entries(team.roles).map(([role, { members }]) => [
                role,
                members.map((m) => `${m.provider}:${m.provider_model_id}`),
              ]),
            ),
          }
        : { default: "the app's own team" },
    ),
  );
}

// Run directly (not imported): print the team for an example query.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
  await main();

Escalation

An escalation is an ordered list: each step is the cheapest model that clears the next quality tier, from a provider no earlier step uses where possible (always, with providers_distinct=true). The tiers run evenly from the lowest score to the single answer's floor; length sets the steps (1 to 5, default 3). When fewer rising tiers exist, the list is shorter and says so in an escalation_shortened warning.

With a strategy or roles instead, it is check then escalate: one model to try first (model and escalation.steps[0]) and the team to escalate to (escalation.team). Your app decides when to escalate.

Planning the shape

POST /v1/plan takes the same shapes, and shape: "auto": a team when the reading says several models would help (reading.ensemble_helps), with your strategy, the purpose's, or red-team for an adversarial request and expert-panel otherwise; an escalation when constraints give a length; else one model. sources.shape says which decided. If no team fits your providers, auto answers one model instead.