AI 이메일 에이전트의 스레드를 사람에게 에스컬레이션하기
요약
AI 이메일 에이전트가 민감한 상황에서 스스로 물러나 사람에게 업무를 넘기는 '핸드오프(handoff)' 구현 방법을 다룹니다. Nylas API를 활용하여 스레드를 특정 폴더로 이동시키고, 에이전트의 개입을 중단시키는 실무적인 설계 방식을 제안합니다.
핵심 포인트
- 에이전트의 실패는 잘못된 답변보다 답변하지 말아야 할 때 답변하는 것에서 발생함
- 핸드오프는 단순 분류가 아닌 스레드 전체를 사람의 관리 영역으로 이동시키는 과정임
- Nylas의 시스템 폴더와 사용자 정의 폴더를 활용해 에스컬레이션 경로 구축 가능
- 에이전트의 동작을 멈추기 위해 서버 측 플래그 대신 자체 데이터베이스 상태 관리 필요
대부분의 "AI 이메일 에이전트 (AI email agent)" 데모는 에이전트가 모든 것에 답변한다고 조용히 가정합니다. 모델을 받은 편지함에 연결하고, 답장을 생성하고, 전송하고, 반복합니다. 모델이 건드려서는 안 될 메시지 — 화난 고객, 법적 질문, 에이전트에게 승인 권한이 없는 환불 요청 등 — 를 만날 때까지는 괜찮은 루프입니다. 하지만 모델은 그럼에도 불구하고 자신 있게 답장을 보내버립니다. 에이전트 이메일에서 발생하는 값비싼 실패는 에이전트가 잘못 답변한 스레드가 아닙니다. 그것은 에이전트가 물러나야 했을 때 답변을 해버린 스레드들입니다.
그러니 물러나는 부분을 만들어 봅시다. 메시지가 위험하다고 결정하는 분류기 (classifier)가 아닙니다. 그것은 triage이며, 별개의 문제입니다. 이것은 핸드오프 (handoff) 입니다. 무언가가 스레드를 "사람 필요"로 플래그를 지정하면, 어떻게 대화 전체를 에이전트의 손이 닿지 않는 곳으로 실제로 빼내고, 사람이 찾을 수 있는 곳에 주차하며, 그 사람이 승인할 때까지 에이전트가 손을 대지 못하게 할 수 있을까요?
저는 Nylas CLI에서 일하기 때문에, 아래의 터미널 명령어들은 제가 에스컬레이션 경로 (escalation path)를 연결할 때 실제로 사용하는 것들입니다. 모든 작업은 두 가지 관점, 즉 원시 curl 호출과 동일한 작업을 수행하는 nylas 명령어를 모두 제공합니다.
핸드오프에 실제로 필요한 것
**에이전트 계정 (Agent Account)**은 근본적으로 grant_id를 가진 Nylas grant일 뿐입니다. 이것이 여기 모든 것의 중추이며, 깊이 생각해 볼 가치가 있습니다. 데이터 평면 (data plane)에서 새로 배울 것은 없습니다. 여러분이 이미 사용하고 있는 동일한 grant 범위의 엔드포인트(endpoints) — Messages, Threads, Folders, Drafts — 는 OAuth를 통해 얻은 Gmail이나 Microsoft grant에 대해 작동하는 방식과 정확히 동일하게 이 grant에 대해서도 작동합니다. 따라서 에스컬레이션 경로는 어떤 특별한 에이전트 기능이 아닙니다. 그것은 여러분이 이미 절반쯤 알고 있는 세 가지 평범한 작업입니다:
- 에스컬레이션된 스레드를 보관할 장소. 에이전트 계정(Agent Account)이 기본적으로 제공하는 6개의 시스템 폴더(
inbox,sent,drafts,trash,junk,archive)와 나란히 존재하는Needs human과 같은 사용자 정의 폴더입니다. - 스레드 전체를 그곳으로 이동시키는 방법. 메시지 하나가 아니라 스레드(thread) 전체를 옮겨야 합니다. 답장은 대화의 가장 최근 메시지일 뿐이며, 검토자에게는 전체 대화 체인이 필요합니다.
- 사람이 승인할 때까지 에이전트가 해당 폴더의 내용에 답장하는 것을 중단시키는 방법.
설계 전체의 방향을 결정짓는 솔직한 사실을 먼저 말씀드리자면, Nylas는 에이전트 계정에 대해 여러분을 위한 "일시 중지(paused)" 플래그를 저장해주지 않습니다. 이러한 권한(grants)에서는 사용자 정의 메타데이터(Custom metadata)가 지원되지 않으므로, "이 스레드는 건드리지 마시오"라고 설정할 수 있는 서버 측 필드가 없습니다. 일시 중지 상태는 여러분의 상태(state)입니다. 즉, thread_id를 키로 사용하는 여러분의 데이터베이스 내의 한 행(row)입니다. 폴더 이동은 사람이 메일 클라이언트에서 볼 수 있는 가시적이고 지속적인 신호이며, 일시 중지 플래그는 에이전트가 답장 초안을 작성하기 전에 확인하는 비가시적인 신호입니다. 여러분은 이 두 가지가 모두 필요하며, 각각 서로 다른 역할을 수행합니다.
시작하기 전에
에이전트 계정(Agent Account)과 해당 계정의 grant_id가 필요합니다. 아직 없다면, `
프로비저닝 문서에서 도메인과 DNS에 대해 다룹니다. 아래의 모든 내용은 이미 nylas init을 실행하여 CLI가 귀하의 애플리케이션을 가리키고 있으며, message.created 웹훅 (webhook)에 의해 구동되는 답장 루프(reply loop)를 갖추고 있다고 가정합니다. 이 루프는 이메일 답장 처리하기 (Handle email replies)에서 설명된 루프입니다. 이 포스트는 해당 루프가 넘겨주는 경계(boundary)에 관한 것입니다.
리뷰 폴더 생성하기 (Create the review folder)
무언가를 라우팅(route)하기 전에 목적지가 반드시 존재해야 합니다. 설정 시점에 커스텀 폴더를 한 번 생성하고, 그 ID를 계속 재사용하세요. API를 사용하는 경우 이름과 함께 POST /v3/grants/{grant_id}/folders를 호출합니다:
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/folders" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
...
응답으로 폴더 객체가 반환됩니다. data.id를 저장하세요 — 이것이 메시지를 이동시킬 폴더 ID입니다. 에이전트(agent)에서 참조하든, 리뷰어가 메일 클라이언트에서 이름으로 폴더를 참조하든 동일한 ID입니다.
CLI를 사용하면 한 줄로 가능합니다:
nylas email folders create "Needs human"
그러면 새 폴더의 ID가 출력됩니다. 시스템 폴더와 함께 잘 생성되었는지 확인하려면 폴더 목록을 조회하세요 — GET /v3/grants/{grant_id}/folders:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/folders" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
또는 CLI에서:
nylas email folders list
Agent Accounts의 훌륭한 속성 중 하나는 API를 통해 생성된 커스텀 폴더가 실제 IMAP 메일함으로 나타난다는 점입니다. 따라서 Needs human 폴더를 생성하는 즉시, 해당 계정에 연결된 모든 사용자의 Outlook, Apple Mail 또는 Thunderbird에 폴더가 나타납니다. 에이전트와 사람은 동일한 메일함을 보고 있으며 — API와 IMAP은 하나의 백엔드 저장소(backing store)를 공유합니다 — 이것이 바로 추가적인 동기화 계층(sync layer) 없이도 이 핸드오프(handoff)가 작동하게 만드는 핵심입니다.
이 작업은 계정당 한 번만 수행하면 됩니다. 에스컬레이션(escalation)이 발생할 때마다 폴더를 생성하지 마세요. 이름으로 폴더를 찾거나(또는 ID를 캐싱하여) 재사용하십시오.
메시지만이 아니라 스레드 전체를 이동하세요
사람들이 흔히 실수하는 부분입니다. 어떤 요소가 대화를 사람의 검토 대상으로 플래그(flag) 지정하면, 본능적으로 플래그를 트리거한 해당 메시지를 이동시키려 합니다. 하지만 Needs human 폴더를 여는 검토자에게는 전체 대화 내용 — 에이전트가 말한 것, 고객이 답장한 것, 그리고 세 메시지 전의 원래 요청 내용 — 이 필요합니다. 메시지 하나만 이동시킨다면, 맥락(context)이 없는 혼란스러운 답변 하나만을 동료에게 넘겨주는 꼴이 됩니다.
따라서 스레드 전체를 이동시켜야 합니다. API를 통해서는 단 한 번의 호출로 가능합니다: PUT /v3/grants/{grant_id}/threads/{thread_id}는 스레드를 업데이트하며, 모든 Nylas PUT 요청과 마찬가지로 전송된 데이터로 중첩된 데이터를 *교체(replaces)*합니다. 검토용 폴더의 ID만 포함된 folders 배열을 전달하면, 대화 내의 모든 메시지가 한 번에 Needs human으로 이동합니다:
curl --request PUT \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/threads/<THREAD_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
...
이 단일 PUT 호출이 가장 깔끔한 경로입니다. 메시지 ID를 가져올 필요도, 루프(looping)를 돌릴 필요도 없습니다. 코드로는 다음과 같습니다:
async function escalateThread(grantId, threadId, needsHumanFolderId) {
// 단 한 번의 호출로 전체 대화를 검토 폴더로 이동합니다.
await nylas.threads.update({
...
여기서 흥미로운 비대칭성은 CLI(Command Line Interface)에 있습니다. nylas email threads는 list, show, mark, delete, search 명령만 노출하며, 스레드를 이동하는(thread-move) 명령은 없습니다. 따라서 터미널에서는 메시지 단위로 이동해야 합니다. 즉, 스레드의 message_ids를 가져온 다음, nylas email move를 사용하여 각 메시지를 이동시키는 방식입니다. 먼저 스레드를 조회하여 메시지를 확인합니다. API를 통해서는 GET /v3/grants/{grant_id}/threads/{thread_id}를 호출하며, 이는 message_ids 배열을 반환합니다.
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/threads/<THREAD_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
CLI에서는 다음과 같습니다:
# 스레드와 해당 메시지 표시
nylas email threads show <thread-id>
...
nylas email move는 하나의 메시지 ID와 목적지 폴더 ID를 지정하는 --folder 플래그를 받습니다. 스레드가 반환하는 모든 메시지 ID에 대해 이 과정을 반복하면 대화가 Needs human 폴더로 함께 이동합니다. 이는 스레드 레벨의 PUT 요청이 한 번에 수행하는 것과 동일한 최종 상태를 만드는 것이지만, CLI에는 스레드 이동 동사(verb)가 없기 때문에 메시지별로 조립하여 수행하는 것입니다.
API 규약(contract)상 반드시 짚고 넘어가야 할 주의사항이 하나 있습니다. folders 배열은 추가(append)가 아니라 교체(replace) 방식이라는 점입니다. ["<NEEDS_HUMAN_FOLDER_ID>"]를 전송하면 스레드(또는 메시지)가 inbox에서 제거되고 오직 검토 폴더에만 위치하게 됩니다. 이는 에스컬레이션(escalation) 시 일반적으로 원하는 동작입니다. 에이전트가 모니터링하는 편지함(inbox)에서 스레드를 빼냄으로써,
스레드를 inbox에서 옮기는 것만으로도 이미 어느 정도의 이점을 얻을 수 있습니다. 만약 당신의 답장 루프(reply loop)가 inbox에 있는 메시지에만 동작한다면, 에스컬레이션된 스레드는 이제 에이전트에게 보이지 않게 됩니다. 많은 에이전트에게는 이 정도로 충분합니다. 하지만 이는 취약합니다. 잘못된 규칙(stray rule), 리뷰어가 읽기 위해 스레드를 다시 드래그하여 옮기는 경우, 혹은 동일한 스레드에 대한 새로운 수신 메시지가 inbox에 도착하는 경우 등 — 이러한 상황 중 어떤 것이라도 메시지를 다시 에이전트 앞에 놓이게 할 수 있습니다. 에이전트의 침묵이 에이전트 스스로 제어할 수 없는 폴더 위치에 의존하게 해서는 안 됩니다.
따라서 명시적인 일시 중지 플래그(pause flag)를 유지하고, 에이전트가 답장 초안을 작성하기 전, 메시지가 들어오는 단계(_in_)에서 이를 확인해야 합니다. 이 부분은 Nylas 필드로 제공되지 않는 영역입니다. 에이전트 계정 권한(Agent Account grants)에서는 커스텀 메타데이터(Custom metadata)가 지원되지 않으므로, "이 스레드는 일시 중지됨"이라는 정보는 thread_id를 키(key)로 하여 당신의 애플리케이션 스토어에 저장되어야 합니다. 여기서 thread_id는 message.created 웹훅(webhook)이 모든 수신 메시지에 대해 전달해 주는 것과 동일한 thread_id입니다.
에스컬레이션은 스레드를 이동시키는 동시에 플래그를 기록합니다:
async function escalate(grantId, threadId, needsHumanFolderId, reason) {
await escalateThread(grantId, threadId, needsHumanFolderId);
...
그리고 답장 루프는 모델 호출(model call)을 하기 전에 이를 가장 먼저 확인합니다. 가장 비용이 적게 드는 가드(guard)는 웹훅 핸들러(webhook handler)의 맨 윗부분, 즉 이벤트가 당신의 에이전트 계정(Agent Account)에 대한 것임을 확인한 직후에 배치합니다:
// message.created 핸들러 내부, 초안을 작성하기 전:
const pause = await db.getThreadPaused(msg.thread_id);
if (pause?.paused) {
...
저 return이 이 포스트의 핵심입니다. 에이전트는 수신된 답장을 확인했고, 해당 스레드가 사람이 검토 중임을 인지했으며, *동작하기를 거부(declined to act)*했습니다. 초안 작성도, 발송도, "확인했습니다"라는 응답도 없이 — 사람이 처리 중인 스레드에 대해 올바른 동작인 '침묵'을 유지한 것입니다.
두 명의 가드, 두 개의 작업, 그리고 서로를 보완하는 구조: **폴더 이동 (folder move)**은 내구성이 있고 사람이 확인할 수 있는 신호(검토자가 Needs human 폴더에 있는 스레드를 보는 것)이며, **일시 중지 플래그 (pause flag)**는 코드가 실제로 신뢰하는 권위 있는 게이트입니다. 만약 두 신호가 일치하지 않는 경우 — 예를 들어 스레드는 이동되었지만 플래그 쓰기에 실패한 경우 — 에이전트가 답장할지 여부를 결정하는 것은 플래그입니다. 데이터베이스 쓰기를 신뢰할 수 있는 원천(source of truth)으로 취급하고, 폴더를 UI로 취급하세요.
사람이 업무를 이어받는 방법
이 지점이 바로 Agent Accounts의 설계가 빛을 발하는 부분입니다. 검토자는 귀하의 앱이나 커스텀 대시보드, 또는 API 접근 권한이 필요하지 않습니다. 해당 계정이 IMAP 및 SMTP를 노출하기 때문에, 사람은 계정 주소를 사용자 이름으로, **앱 비밀번호 (app password)**를 비밀번호로 사용하여 Outlook, Apple Mail, Thunderbird와 같은 표준 메일 클라이언트에서 사서함에 연결할 수 있습니다.
이 앱 비밀번호는 한 번만 설정하면 됩니다. CLI를 통해 생성 시점에 설정할 수 있습니다:
nylas agent account create support@yourcompany.com \
--name "Acme Support" \
--app-password "MySecureP4ssword2024"
(비밀번호 규칙: 18~40자, 출력 가능한 ASCII, 최소 하나의 대문자, 하나의 소문자, 하나의 숫자를 포함해야 함.) 클라이언트의 IMAP 설정을 mail.us.nylas.email의 993 포트로, SMTP 설정을 465/587 포트로 지정하면, 검토자의 메일 클라이언트는 _에이전트가 제어하고 있는 것과 동일한 사서함_을 열게 됩니다. API를 통해 생성한 Needs human 폴더가 일반 폴더처럼 그곳에 나타납니다. 에스컬레이션된 스레드들이 그 안에 나타납니다. 사람은 전체 대화 내용(순서대로 함께 이동된 모든 메시지)을 읽고, 자신의 메일 클라이언트에서 즉시 답장을 보냅니다.
사람이 SMTP를 통해 답장을 보내면, Nylas는 스레딩 헤더(threading headers)를 보존하므로, 그들의 답장은 고객에게 올바르게 스레드로 연결되며 API 측에서도 동일한 스레드에 나타납니다. 에이전트는 별도의 인밴드(in-band) 방식으로 무언가를 전달할 필요가 없었습니다. 공유된 사서함 자체가 바로 핸드오프(handoff)입니다.
당신의 코드가 여전히 제어권을 갖는 단 한 가지는 일시 중지 해제(un-pause)입니다. 사람이 작업을 완료하면 — 보통 스레드에서 사람이 보낸 메시지를 감시하거나, 더 간단하게는 검토자(reviewer)에게 이를 해제할 수 있는 방법을 제공함으로써 이를 감지할 수 있습니다 — 당신은 플래그(flag)를 다시 전환합니다:
await db.setThreadPaused(threadId, { paused: false, clearedAt: Date.now() });
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기