Claude d3689b88fc
feat(onboarding): show the onboarding cards and check-in names in the user's language
The onboarding cards, the closing line and the coach routine names were English server text
for every user. The server keeps the state and an English fallback; the web now words them
through Lingui, so there is one translation source.

- Choice cards: the web says the question, the option labels and the placeholder from the
  step and option ids, and keeps the server text for anything it does not know (quiet-hour
  ranges are already written in the user's locale). Another bot's focus card is recognized
  by its option ids. Mobile and agent history keep the server text.
- The "Got it. … are a good place to start." line is gone: the app cards speak for
  themselves, and with no cards nothing is said.
- The closing line becomes an intake_note block (note "done", nextCheckInAt, English text).
  The web words it with the first check-in in the user's clock; mobile blockText and
  blocksToAgentHistoryText read its text through their generic fallback.
- Routines carry coachKey. The routine list and editor show a check-in under its label in
  the UI language until the user renames it; saving an untouched label keeps the stored
  name. The English names move to contracts (COACH_ROUTINE_NAMES); prompts stay English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:51:59 +00:00

946 lines
33 KiB
TypeScript

import { eventIterator, oc } from "@orpc/contract";
import * as z from "zod";
import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js";
import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js";
import {
CheckInSchema,
COACH_TITLE_MAX_LENGTH,
CoachRhythmSchema,
CoachTodaySchema,
CreateGoalInput,
CreateHabitInput,
GoalSchema,
HabitSchema,
ListCheckInsInput,
ListGoalsInput,
ListHabitsInput,
LogCheckInInput,
UpdateGoalInput,
UpdateHabitInput,
} from "./coach.js";
import {
ActionApprovalRuleSchema,
ActionAutoReviewSettingsSchema,
AgentSecretInputSchema,
AgentSecretSchema,
AgentSkillCatalogEntrySchema,
AgentSkillSchema,
AppBootstrapSchema,
ArtifactSchema,
ArtifactWithContentSchema,
AvatarStyleSchema,
BotMcpServerSchema,
BotSchema,
BotSectionSchema,
CapabilityInstallSchema,
ComputerModeSchema,
ComputerReleaseReasonSchema,
ComputerStatusSchema,
ComputerUpdateSchema,
ConnectionCatalogItemSchema,
ConnectionSchema,
CreateAgentSkillInput,
CreateBotInput,
CreateGroupInput,
CreateRoutineInput,
CreateScratchpadItemInput,
DelegatedTaskSchema,
DeploymentSettingsSchema,
ExportManifestSchema,
ExternalConversationPolicySchema,
GroupDetailSchema,
GroupSchema,
IntegrationCatalogResultSchema,
McpServerConfigInput,
McpServerSchema,
MemoryDocumentSchema,
MemoryScopeSchema,
MeSchema,
MessagingAgentConnectionSchema,
MessagingChannelMembershipSchema,
MessagingLinkedIdentitySchema,
MessagingStatusSchema,
ModelCatalogEntrySchema,
ModelConnectInputSchema,
ModelCredentialSchema,
ModelOAuthBeginSchema,
REPLY_QUOTE_MAX_LENGTH,
ReorderBotsInput,
RoutineSchema,
ScratchpadItemSchema,
ScratchpadItemStatusSchema,
ServerUpdateCheckSchema,
ServerUpdateRequestSchema,
ServerUpdateRunSchema,
ServerUpdateStatusSchema,
SkillPlaybookSchema,
SpaceMemoryConfigSchema,
SpaceNavigationSchema,
SpaceSchema,
TaughtSkillSchema,
TeachRecordingEventSchema,
ThreadMessagePageSchema,
ThreadSnapshotSchema,
UpdateAgentSkillInput,
UpdateBotInput,
UpdateExternalConversationPolicyInput,
UpdateGroupInput,
UsageRecordSchema,
VoiceCatalogEntrySchema,
VoiceCredentialSchema,
VoiceInfoSchema,
VoiceStatusSchema,
} from "./domain.js";
import { DelegatedTaskStatusSchema, OnboardingStepSchema, ProductEventSchema } from "./events.js";
import { Id, IsoDate } from "./ids.js";
import { InboxListOutputSchema } from "./inbox.js";
import {
IntegrationProviderConfigSchema,
IntegrationSetupStateSchema,
} from "./integration-settings.js";
import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js";
import {
ExpoPushTokenSchema,
NotificationSettingsSchema,
UpdateNotificationSettingsInput,
WebPushConfigSchema,
WebPushEndpointSchema,
WebPushSubscriptionInput,
} from "./notifications.js";
import {
OnboardingScanApplyInputSchema,
OnboardingScanApplyResultSchema,
OnboardingScanStatusSchema,
OnboardingScanTargetSchema,
} from "./onboarding-scan.js";
import {
CreateProjectGoalInput,
ProjectGoalSchema,
UpdateProjectGoalInput,
} from "./project-goals.js";
import { ProjectSessionsOutputSchema } from "./project-sessions.js";
import { MessageReactionSchema } from "./reactions.js";
import { RunsListOutputSchema } from "./runs.js";
import { SearchQueryOutputSchema } from "./search.js";
import {
ForgetUserFactsInput,
UpdateUserProfileInput,
UserProfileStateSchema,
} from "./user-profile.js";
const botId = z.object({ botId: Id });
const groupId = z.object({ groupId: Id });
const threadTarget = z
.object({
botId: Id.optional(),
groupId: Id.optional(),
})
.superRefine((input, ctx) => {
const hasBot = Boolean(input.botId);
const hasGroup = Boolean(input.groupId);
if (hasBot === hasGroup) {
ctx.addIssue({
code: "custom",
message: "Provide exactly one of botId or groupId",
path: ["botId"],
});
}
});
const structuredMentionTarget = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("bot"), id: Id }),
z.object({ kind: z.literal("group"), id: Id }),
z.object({ kind: z.literal("routine"), id: Id }),
z.object({ kind: z.literal("connector"), id: Id }),
]);
const threadSendInput = threadTarget
.safeExtend({
text: z.string().optional(),
artifactIds: z.array(Id).max(ATTACHMENT_MAX_COUNT).optional(),
/** Bare bot ids (legacy) or typed mention chips from the composer. */
mentions: z
.array(z.union([Id, structuredMentionTarget]))
.max(64)
.optional(),
replyToMessageId: Id.optional(),
replyQuote: z.string().trim().min(1).max(REPLY_QUOTE_MAX_LENGTH).optional(),
clientNonce: z.string().min(1).max(200).optional(),
})
.superRefine((input, ctx) => {
const text = input.text?.trim() ?? "";
const artifactIds = input.artifactIds ?? [];
if (!text && artifactIds.length === 0) {
ctx.addIssue({
code: "custom",
message: "Provide text or at least one attachment",
path: ["text"],
});
}
if (input.replyQuote && !input.replyToMessageId) {
ctx.addIssue({
code: "custom",
message: "replyQuote requires replyToMessageId",
path: ["replyQuote"],
});
}
});
export const appContract = {
aiConsent: {
status: oc.input(AiConsentQuerySchema).output(AiConsentStatusSchema),
allow: oc
.input(
z.object({
scope: z.string(),
version: z.string(),
keys: z.array(z.string()).min(1).max(200),
}),
)
.output(AiConsentStatusSchema),
revoke: oc.input(z.object({ key: z.string().nullable() })).output(AiConsentStatusSchema),
},
health: oc.output(z.object({ ok: z.literal(true), version: z.string() })),
me: oc.output(MeSchema),
preferences: {
update: oc.input(z.object({ avatarStyle: AvatarStyleSchema })).output(MeSchema),
},
/** What the user's bots know about the user, across spaces. */
profile: {
get: oc.output(UserProfileStateSchema),
update: oc.input(UpdateUserProfileInput).output(UserProfileStateSchema),
forget: oc.input(ForgetUserFactsInput).output(UserProfileStateSchema),
},
/** The user's goals, habits, check-ins and check-in times; owner-only, across spaces. */
coach: {
today: oc.output(CoachTodaySchema),
goals: {
list: oc.input(ListGoalsInput).output(z.array(GoalSchema)),
create: oc.input(CreateGoalInput).output(GoalSchema),
update: oc.input(UpdateGoalInput).output(GoalSchema),
remove: oc.input(z.object({ goalId: Id })).output(z.object({ ok: z.literal(true) })),
},
habits: {
list: oc.input(ListHabitsInput).output(z.array(HabitSchema)),
create: oc.input(CreateHabitInput).output(HabitSchema),
update: oc.input(UpdateHabitInput).output(HabitSchema),
remove: oc.input(z.object({ habitId: Id })).output(z.object({ ok: z.literal(true) })),
},
checkIns: {
log: oc.input(LogCheckInInput).output(CheckInSchema),
list: oc.input(ListCheckInsInput).output(z.array(CheckInSchema)),
},
rhythm: {
get: oc.output(CoachRhythmSchema),
set: oc.input(CoachRhythmSchema).output(CoachRhythmSchema),
},
},
spaces: {
list: oc.output(SpaceNavigationSchema),
create: oc
.input(
z.object({
name: z.string().trim().min(1).max(60),
workspacePath: z.string().trim().min(1).max(500).optional(),
}),
)
.output(SpaceSchema),
remove: oc
.input(z.object({ spaceId: Id }))
.output(z.object({ ok: z.literal(true), activeSpaceId: Id })),
},
bootstrap: oc.input(z.object({ botId: Id.optional() })).output(AppBootstrapSchema),
deployment: {
get: oc.output(DeploymentSettingsSchema),
update: oc
.input(
z.object({
signupsEnabled: z.boolean().optional(),
signupAllowlist: z.array(z.string()).optional(),
computerHost: z.enum(["docker", "this-mac"]).nullable().optional(),
}),
)
.output(DeploymentSettingsSchema),
},
/**
* Deployment-owner product updates. When the Compose updater sidecar is reachable, these proxy
* to its `/state` `/plan` `/apply` contract. Rollback stays on the sidecar for ops only and is
* not exposed here. Never git-fetch from the API process.
*/
updater: {
status: oc.output(ServerUpdateStatusSchema),
check: oc.input(ServerUpdateRequestSchema).output(ServerUpdateCheckSchema),
apply: oc.input(ServerUpdateRequestSchema).output(ServerUpdateRunSchema),
},
models: {
list: oc.output(z.array(ModelCatalogEntrySchema)),
credentials: oc.output(z.array(ModelCredentialSchema)),
connect: oc.input(ModelConnectInputSchema).output(ModelCredentialSchema),
probeOpenAiCompatible: oc
.input(
z.union([
z.object({
baseUrl: z.string(),
apiKey: z.string().optional(),
}),
// A connected server, probed with its stored address and key.
z.object({ credentialId: Id }),
]),
)
.output(z.object({ models: z.array(z.string()) })),
beginOAuth: oc
.input(
z.object({
provider: z.string(),
label: z.string().optional(),
modelId: z.string().optional(),
}),
)
.output(ModelOAuthBeginSchema),
submitOAuthCode: oc
.input(z.object({ loginId: z.string(), code: z.string().trim().min(1).max(8_192) }))
.output(z.object({ ok: z.literal(true) })),
completeOAuth: oc
.input(z.object({ loginId: z.string() }))
.output(
z.discriminatedUnion("status", [
z.object({ status: z.literal("pending") }),
z.object({ status: z.literal("ready") }),
z.object({ status: z.literal("error"), error: z.string() }),
]),
),
finishOAuth: oc.input(z.object({ loginId: z.string() })).output(ModelCredentialSchema),
cancelOAuth: oc
.input(z.object({ loginId: z.string() }))
.output(z.object({ ok: z.literal(true) })),
setDefault: oc
.input(z.object({ provider: z.string(), modelId: z.string() }))
.output(z.object({ ok: z.literal(true) })),
tiers: {
list: oc.output(z.array(ModelTierMappingSchema)),
set: oc.input(SetModelTierInput).output(z.array(ModelTierMappingSchema)),
},
},
bots: {
list: oc.output(z.array(BotSchema)),
listArchived: oc.output(z.array(BotSchema)),
get: oc.input(botId).output(BotSchema),
create: oc.input(CreateBotInput).output(BotSchema),
/** The user's main bot, created with its main conversation in the home space when missing. */
ensureMain: oc.output(BotSchema),
duplicate: oc.input(botId).output(BotSchema),
reorder: oc.input(ReorderBotsInput).output(z.object({ ok: z.literal(true) })),
update: oc.input(UpdateBotInput).output(BotSchema),
/** Builtin tool names this bot's allowlist can choose from. */
builtinTools: oc.input(botId).output(z.array(z.string())),
setComputer: oc.input(z.object({ botId: Id, mode: ComputerModeSchema })).output(BotSchema),
archive: oc.input(botId).output(z.object({ ok: z.literal(true) })),
restore: oc.input(botId).output(z.object({ ok: z.literal(true) })),
remove: oc
.input(z.object({ botId: Id, deleteMemories: z.boolean().default(false) }))
.output(z.object({ ok: z.literal(true) })),
rotateWebhookSecret: oc.input(botId).output(
z.object({
secret: z.string(),
path: z.string(),
webhookConfigured: z.literal(true),
}),
),
},
groups: {
create: oc.input(CreateGroupInput).output(GroupSchema),
list: oc.output(z.array(GroupSchema)),
listArchived: oc.output(z.array(GroupSchema)),
get: oc.input(groupId).output(GroupDetailSchema),
listSessions: oc.input(groupId).output(ProjectSessionsOutputSchema),
duplicate: oc.input(groupId).output(GroupSchema),
update: oc.input(UpdateGroupInput).output(GroupSchema),
archive: oc.input(groupId).output(z.object({ ok: z.literal(true) })),
restore: oc.input(groupId).output(z.object({ ok: z.literal(true) })),
remove: oc.input(groupId).output(z.object({ ok: z.literal(true) })),
},
botSections: {
list: oc.output(z.array(BotSectionSchema)),
create: oc
.input(threadTarget.safeExtend({ name: z.string().trim().min(1).max(60) }))
.output(BotSectionSchema),
update: oc
.input(z.object({ sectionId: Id, name: z.string().trim().min(1).max(60) }))
.output(BotSectionSchema),
},
projectGoals: {
list: oc
.input(
z.object({
sectionId: Id,
groupId: Id.optional(),
seedDefaults: z.boolean().optional(),
}),
)
.output(z.array(ProjectGoalSchema)),
create: oc.input(CreateProjectGoalInput).output(ProjectGoalSchema),
update: oc.input(UpdateProjectGoalInput).output(ProjectGoalSchema),
},
threads: {
head: oc.input(threadTarget).output(
z.object({
threadId: Id,
cursor: z.number().int().min(-1),
}),
),
get: oc.input(threadTarget).output(ThreadSnapshotSchema),
messages: oc
.input(
threadTarget.safeExtend({
before: z.number().int().nonnegative().optional(),
includePeerRuns: z.boolean().optional(),
includePeerReceipts: z.boolean().optional(),
around: z
.object({
messageId: Id.optional(),
seq: z.number().int().nonnegative().optional(),
})
.optional(),
}),
)
.output(ThreadMessagePageSchema),
subscribe: oc
.input(threadTarget.safeExtend({ cursor: z.number().int().min(-1) }))
.output(eventIterator(ProductEventSchema)),
send: oc.input(threadSendInput).output(
z.object({
taskId: Id,
runId: Id,
seq: z.number().int(),
runIds: z.array(Id).optional(),
}),
),
react: oc
.input(
threadTarget.safeExtend({
messageId: Id,
reaction: MessageReactionSchema,
clientNonce: z.string().min(1).max(200),
}),
)
.output(z.object({ ok: z.literal(true) })),
stop: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })),
followUp: oc
.input(threadTarget.safeExtend({ text: z.string().min(1) }))
.output(z.object({ ok: z.literal(true) })),
clear: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })),
answer: oc
.input(
threadTarget.safeExtend({
runId: Id,
messageId: Id,
answer: z.string().min(1),
}),
)
.output(z.object({ ok: z.literal(true) })),
markRead: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })),
markUnread: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })),
},
computer: {
status: oc.input(botId).output(ComputerStatusSchema),
boot: oc.input(botId).output(ComputerStatusSchema),
stop: oc.input(botId).output(ComputerStatusSchema),
recover: oc.input(botId).output(ComputerUpdateSchema),
reset: oc.input(botId).output(ComputerStatusSchema),
update: oc.input(botId).output(ComputerUpdateSchema),
updates: oc.output(z.array(ComputerUpdateSchema)),
releaseInterrupted: oc
.input(z.object({ id: Id, workersStopped: z.literal(true) }))
.output(z.object({ ok: z.literal(true) })),
dismissUpdate: oc.input(z.object({ id: Id })).output(z.object({ ok: z.literal(true) })),
takeover: oc.input(botId).output(z.object({ leaseId: Id, expiresAt: z.string() })),
release: oc
.input(
z.object({
botId: Id,
reason: ComputerReleaseReasonSchema.optional(),
}),
)
.output(z.object({ ok: z.literal(true) })),
input: oc
.input(
z.object({
botId: Id,
kind: z.enum(["key", "pointer", "clipboard", "scroll"]),
payload: z.record(z.string(), z.unknown()),
}),
)
.output(z.object({ ok: z.literal(true) })),
files: oc
.input(z.object({ botId: Id, path: z.string().default("/") }))
.output(
z.array(z.object({ path: z.string(), kind: z.enum(["file", "dir"]), size: z.number() })),
),
readFile: oc
.input(z.object({ botId: Id, path: z.string() }))
.output(z.object({ path: z.string(), content: z.string() })),
screenUrl: oc.input(botId).output(z.object({ url: z.string().nullable() })),
heartbeat: oc.input(botId).output(z.object({ ok: z.literal(true) })),
},
memory: {
list: oc
.input(z.object({ botId: Id.optional(), scope: z.enum(["bot", "user"]).optional() }))
.output(z.array(MemoryDocumentSchema)),
update: oc
.input(z.object({ documentId: Id, content: z.string() }))
.output(MemoryDocumentSchema),
exportMarkdown: oc.input(z.object({ botId: Id.optional() })).output(z.string()),
providerConfig: oc.output(SpaceMemoryConfigSchema.nullable()),
connectProvider: oc
.input(
z.object({
provider: z.string().min(1),
settings: z.record(z.string(), z.string()),
credentials: z.record(z.string(), z.string()),
defaultMemoryScope: MemoryScopeSchema.default("isolated"),
}),
)
.output(SpaceMemoryConfigSchema),
setDefaultScope: oc
.input(z.object({ defaultMemoryScope: MemoryScopeSchema }))
.output(SpaceMemoryConfigSchema),
disconnectProvider: oc.output(z.object({ ok: z.literal(true) })),
},
routines: {
list: oc.input(botId).output(z.array(RoutineSchema)),
create: oc.input(CreateRoutineInput).output(RoutineSchema),
update: oc
.input(
z
.object({
routineId: Id,
name: z.string().optional(),
prompt: z.string().optional(),
crons: z.array(z.string().min(1)).optional(),
timezone: z.string().optional(),
active: z.boolean().optional(),
notify: z.boolean().optional(),
webhookEnabled: z.boolean().optional(),
githubEnabled: z.boolean().optional(),
messageProvider: z
.string()
.min(1)
.max(50)
.regex(/^[a-z0-9._-]+$/i)
.nullable()
.optional(),
/** ISO datetime to arm a never-run one-shot. */
runAt: IsoDate.optional(),
})
.superRefine((value, ctx) => {
if (
value.crons &&
value.crons.length === 0 &&
value.webhookEnabled === false &&
value.githubEnabled === false &&
value.messageProvider === null
) {
ctx.addIssue({
code: "custom",
message: "Add a schedule, webhook, GitHub, or message trigger",
path: ["crons"],
});
}
}),
)
.output(RoutineSchema),
remove: oc.input(z.object({ routineId: Id })).output(z.object({ ok: z.literal(true) })),
testRun: oc
.input(
z.object({
routineId: Id,
clientNonce: z.string().min(1).max(200).optional(),
}),
)
.output(z.object({ runId: Id })),
},
scratchpad: {
list: oc
.input(
z.object({
botId: Id,
status: ScratchpadItemStatusSchema.optional(),
includeDone: z.boolean().optional(),
}),
)
.output(z.array(ScratchpadItemSchema)),
create: oc.input(CreateScratchpadItemInput).output(ScratchpadItemSchema),
update: oc
.input(
z.object({
itemId: Id,
title: z.string().min(1).max(200).optional(),
status: ScratchpadItemStatusSchema.optional(),
notes: z.string().max(4_000).optional(),
}),
)
.output(ScratchpadItemSchema),
remove: oc.input(z.object({ itemId: Id })).output(z.object({ ok: z.literal(true) })),
},
skills: {
list: oc.input(botId).output(z.array(TaughtSkillSchema)),
get: oc.input(z.object({ skillId: Id })).output(TaughtSkillSchema),
start: oc
.input(z.object({ botId: Id, goal: z.string().min(1).max(4000) }))
.output(TaughtSkillSchema),
appendEvent: oc
.input(z.object({ skillId: Id, event: TeachRecordingEventSchema }))
.output(TaughtSkillSchema),
snapshot: oc.input(z.object({ skillId: Id })).output(TaughtSkillSchema),
stop: oc.input(z.object({ skillId: Id })).output(TaughtSkillSchema),
updateDraft: oc
.input(
z.object({
skillId: Id,
name: z.string().optional(),
playbook: SkillPlaybookSchema,
}),
)
.output(TaughtSkillSchema),
save: oc
.input(z.object({ skillId: Id, name: z.string().optional() }))
.output(TaughtSkillSchema),
testRun: oc
.input(z.object({ skillId: Id, prompt: z.string().optional() }))
.output(z.object({ runId: Id })),
remove: oc.input(z.object({ skillId: Id })).output(z.object({ ok: z.literal(true) })),
},
/** Claude Agent Skills (SKILL.md recipes) shared across assistants (not taught/demo skills). Pi already understands this format; we persist and inject them. */
agentSkills: {
list: oc.output(z.array(AgentSkillCatalogEntrySchema)),
get: oc
.input(
z
.object({ skillId: Id.optional(), name: z.string().min(1).max(80).optional() })
.superRefine((input, ctx) => {
if (!input.skillId && !input.name?.trim()) {
ctx.addIssue({
code: "custom",
message: "Provide skillId or name",
path: ["skillId"],
});
}
}),
)
.output(AgentSkillSchema),
create: oc.input(CreateAgentSkillInput).output(AgentSkillSchema),
update: oc.input(UpdateAgentSkillInput).output(AgentSkillSchema),
remove: oc.input(z.object({ skillId: Id })).output(z.object({ ok: z.literal(true) })),
},
capabilities: {
list: oc.output(z.array(CapabilityInstallSchema)),
catalogSearch: oc
.input(
z.object({
query: z.string().trim().max(253).default(""),
usePublicCatalog: z.boolean().default(false),
}),
)
.output(
z.object({
enabled: z.boolean(),
results: z.array(IntegrationCatalogResultSchema),
}),
),
install: oc
.input(
z.object({
kind: z.enum(["skill", "plugin", "mcp", "api", "graphql"]),
name: z.string().min(1).max(120),
source: z.string().min(1).max(2048),
config: z.record(z.string(), z.unknown()).default({}),
credential: z.string().max(16_384).optional(),
}),
)
.output(CapabilityInstallSchema),
remove: oc.input(z.object({ id: Id })).output(z.object({ ok: z.literal(true) })),
},
mcp: {
servers: {
list: oc.output(z.array(McpServerSchema)),
create: oc.input(McpServerConfigInput).output(McpServerSchema),
update: oc
.input(
z.union([
z.object({ id: Id, config: McpServerConfigInput }),
z.object({ id: Id, secret: z.string().min(1).max(16384) }),
]),
)
.output(McpServerSchema),
remove: oc.input(z.object({ id: Id })).output(z.object({ ok: z.literal(true) })),
},
assignments: {
list: oc.input(botId).output(z.array(BotMcpServerSchema)),
all: oc.output(z.array(BotMcpServerSchema)),
approve: oc.input(z.object({ botId: Id, serverId: Id })).output(BotMcpServerSchema),
replace: oc
.input(
z.object({
botId: Id,
assignments: z.array(
z.object({
serverId: Id,
allowAllTools: z.boolean().default(true),
allowedTools: z.array(z.string().min(1).max(200)).max(500).default([]),
}),
),
}),
)
.output(z.array(BotMcpServerSchema)),
},
oauth: {
begin: oc.input(z.object({ serverId: Id, redirectUri: z.string().url() })).output(
z.discriminatedUnion("status", [
z.object({
status: z.literal("authorization_required"),
sessionId: Id,
authorizationUrl: z.string().url(),
}),
z.object({
status: z.enum(["already_connected", "authorization_not_requested"]),
}),
]),
),
complete: oc
.input(z.object({ sessionId: Id, code: z.string().min(1), state: z.string().min(1) }))
.output(z.object({ ok: z.literal(true) })),
disconnect: oc.input(z.object({ serverId: Id })).output(z.object({ ok: z.literal(true) })),
},
},
onboarding: {
/** Checks that the caller may onboard this bot's thread. Posts nothing. */
start: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })),
/**
* Posts the first card: for the main bot the first onboarding question, once per user;
* for another bot its focus card while the thread is still idle.
*/
promptFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })),
/**
* Answers the pending card and posts what follows it: the focus card's app suggestions,
* then the next onboarding question. A stale answer (another step is pending) is ignored.
*/
choose: oc
.input(
z.object({
botId: Id,
step: OnboardingStepSchema.optional(),
optionId: z.string(),
/** The answer in the user's words, with `optionId` "_text"; only the goal card takes one. */
text: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(),
/** This device could still turn on push notifications. */
canEnablePush: z.boolean().optional(),
}),
)
.output(z.object({ ok: z.literal(true) })),
/** Dismisses the pending card; an onboarding question counts as skipped. */
dismissFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })),
/** Flip an app_connect card to connected after authorization completes. */
appConnected: oc
.input(
z.object({ botId: Id, provider: z.string(), connectorId: z.string().default("composio") }),
)
.output(z.object({ ok: z.literal(true) })),
/** The folder a scan would read; null when this account cannot scan, so the step is skipped. */
scanTarget: oc.output(OnboardingScanTargetSchema),
/** Start scanning that folder. Only called after the user agreed. */
scan: oc.output(z.object({ scanId: z.string() })),
scanStatus: oc.input(z.object({ scanId: z.string() })).output(OnboardingScanStatusSchema),
/** Create a project space per chosen project and save the chosen facts. */
applyScan: oc.input(OnboardingScanApplyInputSchema).output(OnboardingScanApplyResultSchema),
},
integrationSetup: {
get: oc.output(IntegrationSetupStateSchema),
save: oc.input(IntegrationProviderConfigSchema).output(z.object({ ok: z.literal(true) })),
},
connections: {
catalog: oc
.input(z.object({ query: z.string().optional(), connectorId: z.string().optional() }))
.output(z.array(ConnectionCatalogItemSchema)),
list: oc.output(z.array(ConnectionSchema)),
begin: oc
.input(
z.object({
connectorId: z.string().default("composio"),
provider: z.string(),
displayName: z.string(),
}),
)
.output(z.object({ connectionId: Id, authorizationUrl: z.string().nullable() })),
complete: oc
.input(z.object({ connectionId: Id, code: z.string().optional() }))
.output(ConnectionSchema),
rename: oc
.input(z.object({ connectionId: Id, displayName: z.string().trim().min(1).max(80) }))
.output(ConnectionSchema),
revoke: oc.input(z.object({ connectionId: Id })).output(z.object({ ok: z.literal(true) })),
/** Tools the connected provider exposes. Read-only; no per-tool allowlist yet. */
tools: oc.input(z.object({ connectorId: z.string(), provider: z.string() })).output(
z.array(
z.object({
name: z.string(),
description: z.string(),
}),
),
),
},
/** External messaging surface: link state, group channels, agent connections. */
messaging: {
status: oc.output(MessagingStatusSchema),
link: {
/** Issue a short-lived code the user sends to the line from a chat app. */
start: oc
.input(z.object({ botId: Id }))
.output(z.object({ code: z.string(), expiresAt: z.string() })),
},
identities: {
setBot: oc
.input(z.object({ identityId: Id, botId: Id }))
.output(MessagingLinkedIdentitySchema),
unlink: oc.input(z.object({ identityId: Id })).output(z.object({ ok: z.literal(true) })),
},
channels: {
list: oc.output(z.array(MessagingChannelMembershipSchema)),
// Addressed by membership, not channel: one user can have two linked
// chat apps in the same group, and each answers for its own agent.
respond: oc
.input(z.object({ membershipId: Id, accept: z.boolean() }))
.output(MessagingChannelMembershipSchema),
leave: oc.input(z.object({ membershipId: Id })).output(z.object({ ok: z.literal(true) })),
},
connections: {
list: oc.output(z.array(MessagingAgentConnectionSchema)),
respond: oc
.input(z.object({ connectionId: Id, accept: z.boolean() }))
.output(MessagingAgentConnectionSchema),
revoke: oc.input(z.object({ connectionId: Id })).output(z.object({ ok: z.literal(true) })),
},
},
approvalRules: {
list: oc.output(z.array(ActionApprovalRuleSchema)),
set: oc
.input(
z.object({
effect: z.enum(["always_allow", "require_approval"]),
matchKind: z.enum(["tool", "connector", "category"]),
matchValue: z.string().min(1),
}),
)
.output(ActionApprovalRuleSchema),
remove: oc.input(z.object({ id: Id })).output(z.object({ ok: z.literal(true) })),
},
autoReview: {
get: oc.output(ActionAutoReviewSettingsSchema),
set: oc.input(z.object({ enabled: z.boolean() })).output(ActionAutoReviewSettingsSchema),
},
artifacts: {
list: oc.input(botId).output(z.array(ArtifactSchema)),
create: oc
.input(
threadTarget.and(
z.object({
name: z.string().min(1).max(255),
mimeType: z.string().min(1),
contentBase64: z.string().min(1).max(ATTACHMENT_MAX_BASE64_LENGTH),
}),
),
)
.output(ArtifactSchema),
get: oc.input(threadTarget.and(z.object({ artifactId: Id }))).output(ArtifactWithContentSchema),
},
usage: {
list: oc.output(z.array(UsageRecordSchema)),
summary: oc.output(
z.object({
inputTokens: z.number(),
outputTokens: z.number(),
runs: z.number(),
}),
),
},
export: {
bot: oc.input(botId).output(ExportManifestSchema),
},
notifications: {
registerPush: oc
.input(z.object({ token: ExpoPushTokenSchema }))
.output(z.object({ ok: z.literal(true) })),
unregisterPush: oc.output(z.object({ ok: z.literal(true) })),
settings: {
get: oc.output(NotificationSettingsSchema),
update: oc.input(UpdateNotificationSettingsInput).output(NotificationSettingsSchema),
},
webPush: {
config: oc.output(WebPushConfigSchema),
subscribe: oc.input(WebPushSubscriptionInput).output(z.object({ ok: z.literal(true) })),
unsubscribe: oc
.input(z.object({ endpoint: WebPushEndpointSchema }))
.output(z.object({ ok: z.literal(true) })),
},
},
search: {
query: oc.input(z.object({ q: z.string().max(200) })).output(SearchQueryOutputSchema),
},
runs: {
list: oc.input(z.object({ filter: z.enum(["active", "recent"]) })).output(RunsListOutputSchema),
},
tasks: {
list: oc
.input(z.object({ threadId: Id.optional(), status: DelegatedTaskStatusSchema.optional() }))
.output(z.array(DelegatedTaskSchema)),
},
/** Asks, approvals, takeovers and unread threads outside the main conversation, in all spaces. */
inbox: {
list: oc.output(InboxListOutputSchema),
},
voice: {
catalog: oc.output(z.array(VoiceCatalogEntrySchema)),
status: oc.output(VoiceStatusSchema),
credentials: oc.output(z.array(VoiceCredentialSchema)),
connect: oc
.input(
z.object({
provider: z.string(),
apiKey: z.string().min(8),
voiceId: z.string().max(120).optional(),
}),
)
.output(VoiceCredentialSchema),
disconnect: oc
.input(z.object({ provider: z.string().min(1) }))
.output(z.object({ ok: z.literal(true) })),
setVoice: oc
.input(z.object({ voiceId: z.string().min(1).max(120), provider: z.string().optional() }))
.output(VoiceStatusSchema),
voices: oc
.input(z.object({ provider: z.string().optional() }))
.output(z.array(VoiceInfoSchema)),
prepare: oc
.input(
z.object({
text: z.string().max(20000),
voiceId: z.string().max(120).optional(),
botId: Id.optional(),
}),
)
.output(z.object({ ready: z.boolean(), utterances: z.array(z.string()) })),
},
externalConversations: {
updatePolicy: oc
.input(UpdateExternalConversationPolicyInput)
.output(ExternalConversationPolicySchema),
},
agentSecrets: {
list: oc.output(z.array(AgentSecretSchema)),
put: oc.input(AgentSecretInputSchema).output(AgentSecretSchema),
remove: oc.input(z.object({ id: Id })).output(z.object({ ok: z.literal(true) })),
},
};
export type AppContract = typeof appContract;