ctx API
앱 코드가 플랫폼과 대화하는 유일한 통로인 ctx의 전체 타입, Result 모양, 도구와 트리거 예시, 테스트 작성법.
앱 코드는 @tiffy/app-sdk가 주는 ctx 객체 하나로만 플랫폼과 대화합니다. 전체 타입은 @tiffy/app-sdk가 내보내는 AppContext입니다.
ctx가 주는 것
아래 "상태" 열은 플랫폼이 지금 실제로 무엇을 하는지입니다. 구현됨은 실제 데이터를 읽고 씁니다. 준비 중은 SDK에 선언은 되어 있지만 플랫폼에서 아직 실제 동작이 없어, 부르면 not_implemented 같은 에러로 실패한다는 뜻입니다.
| 호출 | 하는 일 | 필요한 scope | 상태 |
|---|---|---|---|
ctx.user | id, locale, timezone. email은 user.email scope가 있을 때만 채워진다 | 기본 | 구현됨 |
ctx.space | { id } | 기본 | 구현됨 |
ctx.kv.get/set/delete(key) | 앱 전용 JSON 저장 | 기본 | 구현됨 |
ctx.secrets.get/set/delete(name) | 봉인 저장 | secrets.own | 구현됨 |
ctx.files.list/get/put/delete(path) | 앱 전용 파일 폴더. get과 put은 base64 본문을 주고받는다 | files.own | 준비 중 |
ctx.fetch(url, init) | 선언한 호스트로만 나가는 HTTP. 본문은 문자열, 바이너리는 base64 | network:<host> | 구현됨(아래 "fetch 프록시" 참고) |
ctx.notes.search/get/getByPath/create/update | 사용자의 노트 | notes.read, notes.write | 구현됨 |
ctx.notes.attach | 노트에 파일 붙이기 | notes.write | 준비 중 |
ctx.channels.send({ text }) | 사용자의 연결된 채널로 알림 | channels.send | 구현됨 |
ctx.tools.call(key, input) | 다른 앱의 도구 | tool:<key> | 준비 중 |
ctx.events.emit({ kind, dedupeKey?, payload }) | 트리거에서 이벤트 발행 | 기본 | 구현됨 |
ctx.approvals.request({ title, description?, payload }) | 승인 요청. { approvalId }를 돌려받는다. jobId가 있는 호출(도구 실행)에서만 된다 | 기본 | 구현됨 |
ctx.oauth.token(provider) | 유효한 액세스 토큰. 리프레시는 호스트가 대행 | oauth 선언 | 준비 중 |
ctx.model.complete({ messages, tier? }) | 도구 없는 단일 모델 호출. 사용자 크레딧 차감 | model.call | 구현됨(아래 "모델 호출" 참고) |
ctx.log.info/warn/error(message, fields?) | 구조화 로그. 응답을 기다리지 않는다(fire-and-forget) | 기본 | 구현됨(본문은 로그에 남기지 않고 method만 남긴다) |
ctx.clock.now() | 호스트의 현재 시각. Promise<Date> | 기본 | 구현됨 |
ctx.i18n.t(key, vars?) | 사용자 로케일 문구 | 기본 | 준비 중. 지금은 key를 그대로 문자열로 돌려준다 |
ctx.notes.get(noteId)은 id로, ctx.notes.getByPath(path)는 노트 경로(wiki/README.md처럼 영역으로 시작하는 경로)로 찾습니다. 둘 다 없으면 예외가 아니라 { ok: true, value: null }을 돌려줍니다. getByPath는 소유자 확인 없이 같은 space의 노트를 전부 찾을 수 있다는 점이 get(노트를 만든 사용자 소유인지 확인)과 다릅니다. 노트가 space 단위로 공유되는 자료이기 때문입니다.
fetch 프록시: ctx.fetch
ctx.fetch만 웹의 ctx 라우트를 거치지 않고 워커 호스트가 직접 내보냅니다(나머지 전부는 ctx 라우트로 대행됩니다). 검사 순서는 다음과 같습니다.
http:/https:이외의 프로토콜은 즉시 거부(network_denied).- 매니페스트
scopes의network:<host>선언과 대조. 정확히 일치하거나*.example.com처럼 한 단계짜리 와일드카드만 허용. - 호스트명을 DNS로 풀어 사설망(10/8, 172.16/12, 192.168/16), 루프백(127/8,
::1), 링크로컬(169.254/16,fe80::), 멀티캐스트 이상 대역,localhost/*.internal계열을 모두 거부(network_denied). - 응답이 30초를 넘기면 타임아웃, 본문이 10MB를 넘으면
response_too_large. - 3xx 리다이렉트는 자동으로 따라가지 않고 매 hop마다 1~3번 검사를 다시 합니다(최대 5회). 그러지 않으면 허용된 호스트가 내부 주소로 302를 돌려 허용 목록을 우회할 수 있습니다(SSRF). 303이거나 GET/HEAD가 아닌 요청의 301/302는 브라우저처럼 GET으로 바뀝니다.
이 검사는 createMockContext의 scopes/fetchResponses로는 재현되지 않습니다. DNS 조회와 사설 주소 거부는 실제 워커 호스트에서만 일어나는 방어이고, 테스트는 network: scope 일치만 봅니다.
모델 호출: ctx.model.complete
매니페스트에 "model": { "calls": true }를 적고 model.call scope를 선언한 앱만 모델을 부를 수 있습니다. 설치 화면이 그 사실을 사용자에게 보여 줍니다.
const completion = await ctx.model.complete({
tier: "cheap",
messages: [
{ role: "system", content: "You classify emails." },
{ role: "user", content: subject },
],
});
if (!completion.ok) return completion;
// completion.value: { text, inputTokens, outputTokens, modelId }
messages의role은system,user,assistant.system메시지는 합쳐져 시스템 프롬프트가 되고 나머지가 대화가 됩니다. 본문 총합 20,000자를 넘으면too_long입니다.tier는cheap(기본값, 지금 쓸 수 있는 가장 싼 모델)과default(사용자가 고른 기본 모델) 둘입니다. 분류와 추출에는cheap, 사용자가 읽을 글에는default를 쓰세요.- 도구 없는 단일 호출이고 한도는 60초입니다. 대화를 이어 가는 기능은 없습니다.
- 비용은 사용자의 모델 설정과 크레딧에서 나갑니다. 앱은 모델 키를 보지 못하고,
modelId도 사용자 설정에 따라 달라지므로 고정으로 기대하면 안 됩니다. - 작업 없이 도는 경로(트리거 스케줄러)에서는 부를 수 없습니다(
no_job_context). 모델이 필요한 일은 트리거가 이벤트를 내고 그 이벤트로 만들어진 작업에서 하세요.
테스트에서는 createMockContext의 modelReplies(문자열 배열 또는 함수)와 modelId로 응답을 고정하고, modelCalls로 무엇을 넘겼는지 확인합니다.
도구 실행 자원 한도
워커 호스트가 앱을 자식 프로세스로 띄운 뒤 지키는 한도입니다. 넘으면 프로세스를 SIGKILL하고 결과를 { ok: false, error: "timeout", retryable: false }로 돌려줍니다.
| 종류 | 시간 한도 |
|---|---|
| tool | 60초 |
| trigger | 120초 |
lifecycle 훅(onInstall 등) | 60초 |
| homeCard 훅 | 60초 |
Result 모양
ctx를 부르는 대부분의 호출은 예외를 던지지 않고 Result<T>를 돌려줍니다.
type Result<T> =
| { ok: true; value: T }
| {
ok: false;
error: {
code: string;
message: string;
retryable?: boolean;
[k: string]: unknown;
};
};
ok와 err 헬퍼가 @tiffy/app-sdk에서 나옵니다.
알아 둘 에러 코드
| 코드 | 어디서 | 의미 |
|---|---|---|
conflict | ctx.notes.update | 낙관적 잠금 실패. expectedVersion이 실제 버전과 다르다. currentVersion과 currentBody가 함께 온다 |
not_found | ctx.notes.get, update | 노트를 찾을 수 없다 |
scope_denied | ctx.fetch (테스트에서) | 요청 호스트가 scopes의 network:로 선언되지 않았다 |
oauth_not_connected | ctx.oauth.token | 해당 provider의 연결이 없다 |
unknown_tool | ctx.tools.call (테스트에서) | 모의 응답이 없는 도구를 호출했다 |
no_mock_response | ctx.fetch (테스트에서) | 그 URL에 대한 모의 응답을 등록하지 않았다 |
network_denied | ctx.fetch (실제 워커) | 프로토콜이 http/https가 아니거나, 호스트가 network: scope 밖이거나, 사설·루프백·메타데이터 주소로 풀렸다 |
too_many_redirects | ctx.fetch (실제 워커) | 리다이렉트가 5회를 넘었다 |
response_too_large | ctx.fetch (실제 워커) | 응답 본문이 10MB를 넘었다 |
timeout | ctx.fetch(30초), 도구 실행(60초), 트리거 실행(120초) | 자원 한도를 넘겨 강제 종료됐다 |
not_implemented | ctx.notes.attach, ctx.tools.call, ctx.oauth.token | 아직 실제 동작이 없는 method다 |
model_not_allowed | ctx.model.complete | 매니페스트에 model.calls: true가 없다 |
no_job_context | ctx.model.complete | 작업 없이 도는 경로(트리거)에서 불렀다 |
too_long | ctx.model.complete | 메시지 본문 총합이 20,000자를 넘었다 |
kv_key_too_large / kv_space_full | ctx.kv.set | 키 값이 64KB를 넘었거나, 이 앱의 영역별 kv 총량이 10MB를 넘었다 |
도구 예시
// src/tools/draftReply.ts
import type { ToolHandler } from "@tiffy/app-sdk";
export const draftReply: ToolHandler = async (ctx, input) => {
const { messageId } = input as { messageId: string };
const found = await ctx.notes.search(messageId, { limit: 1 });
if (!found.ok) return found;
const completion = await ctx.model.complete({
messages: [
{ role: "system", content: "You draft concise email replies." },
{ role: "user", content: `Draft a reply to: ${messageId}` },
],
});
if (!completion.ok) return completion;
return { ok: true, value: { draft: completion.value.text } };
};
// src/index.ts
import { defineApp } from "@tiffy/app-sdk";
import { draftReply } from "./tools/draftReply";
import { mailArrived } from "./triggers/mailArrived";
export default defineApp({
tools: { "mail.draftReply": draftReply },
triggers: { "mail.arrived": mailArrived },
});
트리거 예시
// src/triggers/mailArrived.ts
import type { TriggerHandler } from "@tiffy/app-sdk";
export const mailArrived: TriggerHandler = async (ctx) => {
const cursor = await ctx.kv.get<string>("last-uid");
const lastUid = cursor.ok ? (cursor.value ?? "0") : "0";
const res = await ctx.fetch(`https://imap.example.com/messages?since=${lastUid}`);
if (!res.ok) {
ctx.log.warn("mail.arrived poll failed", { code: res.error.code });
return;
}
const messages = JSON.parse(res.body) as { id: string }[];
for (const message of messages) {
await ctx.events.emit({ kind: "mail.arrived", dedupeKey: message.id, payload: message });
}
if (messages.length > 0) {
await ctx.kv.set("last-uid", messages[messages.length - 1].id);
}
};
트리거는 값을 돌려주지 않습니다(Promise<void>). 결과는 ctx.events.emit으로 냅니다.
테스트 예시
@tiffy/app-sdk/testing의 createMockContext는 DB 없이 메모리로 도는 모의 ctx를 만들고, 무엇이 불렸는지 전부 배열로 들여다볼 수 있게 해 줍니다.
// test/mailArrived.test.ts
import { describe, expect, it } from "vitest";
import { createMockContext } from "@tiffy/app-sdk/testing";
import { mailArrived } from "../src/triggers/mailArrived";
describe("mail.arrived", () => {
it("fetches the declared host and moves the kv cursor forward", async () => {
const { ctx, kvStore, fetchCalls, eventsEmitted } = createMockContext({
scopes: ["network:imap.example.com"],
fetchResponses: {
"https://imap.example.com/messages?since=0": {
status: 200,
headers: {},
body: JSON.stringify([{ id: "42" }]),
},
},
});
await mailArrived(ctx);
expect(fetchCalls).toEqual([{ url: "https://imap.example.com/messages?since=0" }]);
expect(kvStore.get("last-uid")).toBe("42");
expect(eventsEmitted).toEqual([{ kind: "mail.arrived", dedupeKey: "42", payload: { id: "42" } }]);
});
it("모의 응답이 없으면 경고 로그를 남기고 커서를 건드리지 않는다", async () => {
// scopes와 fetchResponses를 모두 생략했으므로 ctx.fetch는 no_mock_response로 실패한다
const { ctx, kvStore, log } = createMockContext({});
await mailArrived(ctx);
expect(kvStore.has("last-uid")).toBe(false);
expect(log.some((l) => l.level === "warn")).toBe(true);
});
});
// test/draftReply.test.ts
import { describe, expect, it } from "vitest";
import { createMockContext } from "@tiffy/app-sdk/testing";
import { draftReply } from "../src/tools/draftReply";
describe("mail.draftReply", () => {
it("returns a draft from the model reply", async () => {
const { ctx } = createMockContext({
modelReplies: ["Thanks, I'll get back to you tomorrow."],
});
const result = await draftReply(ctx, { messageId: "msg_1" });
expect(result.ok).toBe(true);
});
it("rejects a fetch to a host that isn't declared in scopes", async () => {
const { ctx } = createMockContext({ scopes: ["network:imap.example.com"] });
const res = await ctx.fetch("https://evil.example.com/steal");
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error.code).toBe("scope_denied");
});
});
createMockContext가 받는 옵션 중 자주 쓰는 것
scopes: 주면ctx.fetch가network:선언을 검사한다. 안 주면 모든 호스트를 허용한다.fetchResponses: URL별 고정 응답, 또는(url, init) => AppFetchResponse함수.modelReplies: 순서대로 소비되는 문자열 배열, 또는 함수.toolResponses:ctx.tools.call이 돌려줄 응답을 키별로 미리 등록한다.i18nMessages:{ locale: { key: template } }모양.{{name}}플레이스홀더를 치환한다.
돌려받는 값 중 자주 쓰는 것: kvStore, secretsStore, notesStore, filesStore, channelsSent, fetchCalls, eventsEmitted, approvalsRequested, toolsCalled, log.