agent-transcript-logging

Use this skill whenever writing any code that creates, stores, queries, exports, or retains AI agent conversation records in the Myelin platform. This defines the transcript entry schema, retention ru

SKILL.md

GitHub

This skill defines the transcript and audit logging contract for the Tau AI learning coach. Every message sent by a learner and every response generated by the agent must be logged before the response is delivered. Transcripts are audit-grade records — they cannot be edited, deleted, or delayed.


Core Principle: Write Before Render

The agent response must not be rendered to the learner until the transcript entry is committed to the database. A failed transcript write is a blocking error — surface it to the session layer and retry before proceeding. Do not silently swallow transcript write failures.

async function commitAndRender(transcriptEntry, renderFn) {
  try {
    await writeTranscriptEntry(transcriptEntry);
  } catch (err) {
    // Log the failure and retry once
    console.error('Transcript write failed:', err);
    await writeTranscriptEntry(transcriptEntry); // retry
    // If second attempt fails, throw — do not render without a committed transcript
  }
  renderFn(); // only reached if write succeeded
}

Transcript Entry Schema

Every agent interaction — every learner message and every agent response — produces one transcript entry.

{
  "transcriptId":   "uuid v4 — unique per entry",
  "sessionId":      "uuid — current learning/simulation session",
  "conversationId": "uuid — groups all entries for one continuous chat session",
  "learnerId":      "string — tenant-scoped identifier, never raw email",
  "tenantId":       "string — institution identifier",
  "timestamp":      "ISO 8601 UTC",

  "role":           "LEARNER | AGENT",
  "messageContent": "string — full message text (learner input or agent output)",

  "agentMode":      "TEXT | GUIDED_MEDIA | LIVE_CONVERSATIONAL | null",

  "simulationContext": {
    "activeModuleId":  "string | null",
    "activeModuleTitle": "string | null",
    "activeStepId":    "string | null — e.g. LOTO_STEP_03, or null if not in simulation",
    "attemptCount":    "integer | null"
  },

  "mediaReferences": [
    {
      "contentId":  "string — approved content registry ID",
      "title":      "string",
      "url":        "string"
    }
  ],

  "citedSources": ["string — approved URLs referenced in this response"],

  "policyFlag":     "ASSESSMENT_BOUNDARY | SAFETY_REDIRECT | DISTRESS | OUT_OF_SCOPE | null",

  "escalationId":   "uuid | null — links to escalation_queue entry if policyFlag is DISTRESS",

  "demoMode":       "boolean — true if this entry belongs to a demo session",

  "tokenUsage": {
    "promptTokens":     "integer | null",
    "completionTokens": "integer | null"
  }
}

Required fields

All fields above are required. The following may be null when genuinely not applicable:

  • agentMode (null for LEARNER role entries)
  • simulationContext.* (null when learner is not in simulation)
  • mediaReferences (empty array, not null, when no references)
  • citedSources (empty array, not null)
  • policyFlag (null when no policy trigger)
  • escalationId (null unless DISTRESS flag fired)
  • tokenUsage.* (null if not available from the LLM provider)

messageContent must never be null or empty string. An empty response from the agent is a system error — do not log it as a valid entry.


Conversation Grouping

conversationId groups all entries that belong to one continuous chat session between the learner and the agent. It is separate from sessionId (the simulation/learning session).

Rules:

  • A new conversationId is generated when the learner opens the coach panel for the first time in a session
  • A new conversationId is generated if the learner closes and reopens the coach panel after more than 30 minutes of inactivity
  • conversationId is preserved across mode switches within the same chat session
  • The same conversationId must appear on both the learner's message entry and the agent's response entry for each exchange

Retention Rules

Transcript retention is 2 years from the timestamp of each entry, per the platform data retention policy (PRD Section 12).

Retention is enforced by the tenant's data retention automation. The platform must:

  1. Tag each entry with retentionExpiresAt = timestamp + 2 years at write time
  2. Run a nightly retention job that archives (not deletes) entries past their expiry
  3. Never delete a transcript entry that is linked to an open escalation (escalationId is non-null and escalation status is not RESOLVED or DISMISSED)

Demo transcripts (demoMode: true) follow the same 2-year retention rule but are stored in the demo namespace and excluded from learner-facing transcript views and all institution reporting.


Demo Namespace Isolation

Demo transcript entries must be written to demo_agent_transcripts, not agent_transcripts. The two tables must never be joined in any query.

async function writeTranscriptEntry(entry) {
  const table = entry.demoMode ? 'demo_agent_transcripts' : 'agent_transcripts';
  await db.query(
    `INSERT INTO ${table} (...fields) VALUES (...values)`,
    buildValues(entry)
  );
}

Live transcript queries (learner-facing chat history, lecturer review, audit exports) must always filter WHERE demo_mode = false as a structural safeguard, even if the query is scoped to a live tenant.


Escalation Linkage

When a policyFlag: 'DISTRESS' event fires, the transcript entry and the escalation queue entry must be linked:

  1. Write the escalation queue entry first (see agent-safety-guardrails skill for escalation schema)
  2. Capture the returned escalationId
  3. Include escalationId in the transcript entry
  4. Write the transcript entry
async function handleDistressEvent(message, context, agentResponse) {
  // 1. Write escalation first
  const escalation = await writeEscalationEntry({
    sessionId:       context.sessionId,
    learnerId:       context.learnerId,
    tenantId:        context.tenantId,
    triggerMessage:  message,
    escalationType:  'DISTRESS',
    timestamp:       new Date().toISOString(),
    slaDeadline:     new Date(Date.now() + 15 * 60 * 1000).toISOString()
  });

  // 2. Write learner message transcript entry with escalation link
  await writeTranscriptEntry({
    ...buildLearnerEntry(message, context),
    policyFlag:   'DISTRESS',
    escalationId: escalation.escalationId
  });

  // 3. Write agent response transcript entry with escalation link
  await writeTranscriptEntry({
    ...buildAgentEntry(agentResponse, context),
    policyFlag:   'DISTRESS',
    escalationId: escalation.escalationId
  });

  // 4. Render response
  renderResponseToUI(agentResponse);
}

Transcript Queries

Learner-facing chat history

async function getLearnerTranscripts(session, limit = 40) {
  await db.query(`SET LOCAL app.current_tenant_id = '${session.tenantId}'`);
  return db.query(`
    SELECT transcript_id, role, message_content, agent_mode,
           simulation_context, media_references, cited_sources, timestamp
    FROM agent_transcripts
    WHERE tenant_id = $1
      AND learner_id = $2
      AND conversation_id = $3
      AND demo_mode = false
    ORDER BY timestamp ASC
    LIMIT $4
  `, [session.tenantId, session.learnerId, session.conversationId, limit]);
}

Lecturer review — all transcripts for a cohort learner

async function getLecturerTranscriptView(lecturerSession, learnerId) {
  requireRole(lecturerSession, ['LECTURER', 'ADMIN']);
  await db.query(`SET LOCAL app.current_tenant_id = '${lecturerSession.tenantId}'`);
  return db.query(`
    SELECT t.transcript_id, t.role, t.message_content, t.agent_mode,
           t.simulation_context, t.policy_flag, t.escalation_id, t.timestamp
    FROM agent_transcripts t
    WHERE t.tenant_id = $1
      AND t.learner_id = $2
      AND t.demo_mode = false
    ORDER BY t.timestamp DESC
  `, [lecturerSession.tenantId, learnerId]);
}

Lecturers may only query transcripts for learners in their assigned cohorts. The cohort membership check must be applied before this query is executed.

Audit export — full transcript pack for a session

async function getAuditTranscriptPack(adminSession, targetSessionId) {
  requireRole(adminSession, ['ADMIN', 'SUPER_ADMIN']);
  await db.query(`SET LOCAL app.current_tenant_id = '${adminSession.tenantId}'`);
  return db.query(`
    SELECT *
    FROM agent_transcripts
    WHERE tenant_id = $1
      AND session_id = $2
      AND demo_mode = false
    ORDER BY timestamp ASC
  `, [adminSession.tenantId, targetSessionId]);
}

Audit export packs must include all entries for the session in chronological order, with policyFlag, escalationId, and citedSources fields preserved.


PII Handling in Transcript Content

messageContent contains free-text from the learner. It may contain PII — names, contact details, personal circumstances. The following rules apply:

  • Do not index messageContent in any full-text search system accessible to staff dashboards — it is a raw audit record, not a searchable knowledge base
  • Redact messageContent in any analytics aggregation or reporting export that is not an explicit audit request
  • If a learner submits a data subject access request, their transcripts must be included in the export
  • If a learner revokes consent, transcripts are retained (they are audit records) but must be flagged with consentRevokedAt and excluded from any active coaching context

Token Usage Tracking

tokenUsage is logged for cost monitoring and capacity planning. It is not exposed to learners or lecturers. It is available to Super Admin and in operational dashboards only.

If the LLM provider does not return token counts in the response, set both fields to null — do not estimate.


What Not To Do

  • Do not render a response before the transcript entry is committed
  • Do not silently catch transcript write failures — retry once, then throw
  • Do not write demo transcripts to the live agent_transcripts table
  • Do not allow messageContent to be null or empty string
  • Do not link an escalation entry to a transcript entry in the wrong order — escalation is written first, then transcript
  • Do not expose messageContent in staff-facing analytics aggregates
  • Do not delete transcript entries linked to open escalations
  • Do not omit demoMode from any transcript entry — it must be an explicit boolean
  • Do not query agent_transcripts without a demo_mode = false filter in live contexts