Forward redacted Amazon Connect conversation transcripts to ciopulse, from your own AWS account, in about fifteen minutes.
This package is a small AWS Lambda function plus a SAM template. It watches the redacted output of Amazon Connect's conversational analytics in your S3 bucket, maps each finished contact to the ciopulse send-a-copy payload, and POSTs it to ciopulse. Nothing else is created in your account, no audio is ever read, and ciopulse never holds credentials to your systems.
Status: v0.2.0 preview · sends send-a-copy contract v0.3 · Python 3.12, boto3 only · MIT licence · see CHANGELOG for what is verified and what is not
- What this is
- Prerequisites
- Deploy in 15 minutes
- What gets sent, and what never leaves your account
- Post-conversation survey (SMS and chat)
- Metrics, alarms and troubleshooting
- Testing locally
- Contract reference
- Design notes
ciopulse measures the experience people have with your AI service agent by combining two lenses: what the person says in a short post-conversation survey, and what the transcript shows. This quickstart supplies the second lens. It runs inside your account, reads only the transcripts Connect has already redacted, and sends them to one ciopulse endpoint.
The survey lens is a Connect flow change, not code. Section 5 walks through it.
| Requirement | Why | Where |
|---|---|---|
| Conversational analytics enabled on every flow a contact can pass through | Transcripts only exist when a Set recording and analytics behavior block has analytics on. Recording alone produces audio, not text. A contact transferred by an agent runs your transfer to queue flow, so that flow needs the block too; see section 3, step 2. |
Flow designer, and the instance-level analytics setting |
redaction_option = RedactedOnly (recommended) or RedactedAndOriginal |
The forwarder reads the …/Redacted/ prefix and nothing else. With RedactedOnly, an unredacted transcript is never written anywhere. |
Set contact attributes block, or a Lambda in the flow, before analytics starts |
| The analytics S3 bucket name | The event rules and the IAM policy are scoped to it. | Connect console → your instance → Data storage → Chat transcripts / Call recordings |
| A ciopulse API key in Secrets Manager | The Lambda reads the key at runtime. The template takes the secret's ARN, never the key. | aws secretsmanager create-secret --name ciopulse/api-key --secret-string '<key>' |
| AWS SAM CLI and permissions to create a Lambda, IAM role, EventBridge rules and an alarm | Deployment | brew install aws-sam-cli or AWS docs |
| An SMS-capable phone number claimed to the instance | Only if you use the post-call SMS survey (section 5) | Connect console → Channels → Phone numbers |
The forwarder does not need Kinesis, EventBridge Contact Lens events, the analytics data lake, or any real-time API.
git clone https://github.com/ciopulse/ciopulse-connect-quickstart.git
cd ciopulse-connect-quickstart
sam deploy --guidedNo sam build step: the function has no dependencies beyond boto3, which the Lambda runtime provides, so SAM packages the source directory as it is. (sam build would insist on a local Python 3.12; if you ever add a dependency, run sam build --use-container instead.)
The guided deploy asks for these values. Five have no default and you must supply them:
| Parameter | Required | What to enter |
|---|---|---|
TranscriptBucketName |
✔ | The analytics bucket, for example amazon-connect-abc123 |
ConnectInstanceArn |
✔ | arn:aws:connect:<region>:<account>:instance/<id>. Used for read-only contact metadata (timestamps, queue, attributes) |
ApiKeySecretArn |
✔ | ARN of the Secrets Manager secret holding your ciopulse API key |
AgentId |
✔ | A stable name for your AI agent, for example service-desk-agent. This is how it appears in ciopulse |
AgentVersion |
✔ | Your release string, for example 1.4.2. Bump it on each release: ciopulse scorecards are per version |
CiopulseEndpoint |
Defaults to the production endpoint. Point it at a test receiver during setup | |
VoiceKeyPattern / ChatKeyPattern |
Default to *Analysis/Voice/Redacted/*.json and *Analysis/Chat/Redacted/*.json. See section 9 before changing |
|
ExcludeQueueNames |
Comma-separated queue names whose contacts are sent with exclude: true (counted by ciopulse, never stored) |
|
OutcomeAttributeName |
A contact attribute your flow sets to contained, escalated or abandoned. Leave blank to send unknown |
|
SkipMultiPartyContacts |
true to skip contacts with more than two participants. Default false |
|
AgentParticipantRoles |
Participant roles mapped to the AI agent. Default AGENT,BOT,CUSTOM_BOT,SYSTEM (flow and bot messages carry SYSTEM) |
|
AlarmSnsTopicArn |
Optional SNS topic for the failed-delivery alarm |
Two steps after the stack is up.
Step 1: let the bucket emit events. The forwarder listens for S3 "Object Created" events through EventBridge, and an existing bucket does not emit those until you switch them on. In the S3 console open the bucket → Properties → Amazon EventBridge → On. Or, if the bucket has no other event notifications configured:
aws s3api put-bucket-notification-configuration \
--bucket <TranscriptBucketName> \
--notification-configuration '{"EventBridgeConfiguration": {}}'That CLI call replaces the bucket's whole notification configuration, so use the console toggle if other notifications already exist. The stack's EnableEventBridgeCommand output repeats the command with your bucket name filled in.
Step 2: turn on analytics in every flow a contact can pass through. Contact Lens is switched on per flow, by a Set recording and analytics behavior block, not once for the whole instance. Your inbound flow is not enough. When an agent transfers a contact to a queue or through a quick connect, Connect starts a new contact that runs your transfer to queue flow, and the stock one has no such block. Without it the transferred part of the conversation is never analysed, and ciopulse sees only what happened before the handoff: the bot and the first agent, but not the agent the customer was passed to. Add the block to every transfer-to-queue and transfer-to-agent flow your quick connects and flows route into, with the same settings as your inbound flow: analytics on, redaction on, output Redacted only.
Check it. Make one test call or chat and wait for analytics to finish; on a live chat the transcript reached the receiver about three minutes after the chat ended. A Forwarded count of 1 in CloudWatch, or a 202 in the Lambda log group, means ciopulse accepted it. Then run one test transfer: have an agent answer, transfer the contact through a quick connect, and exchange a few messages after the transfer. Two analysis files should appear under the Analysis/…/Redacted/ prefix, with different contact IDs at the start of their file names. If only one appears, a flow on the transfer path is missing the block.
Sent, per contact, as one JSON document over HTTPS:
session_id: the ConnectContactIdof this leg.conversation_id: Connect'sInitialContactId. It equalssession_idunless an agent transferred the contact, in which case every leg carries the first leg's ID. This is thetidon the survey link, which is how survey and transcript join.agent.id,agent.version: the two template parameters.channel:voiceorchat, from the S3 path.started_at,ended_at: from the contact record. Voice turn timestamps count from the moment the call connected to an agent, which is when Connect's analytics start.turns[]: the redacted transcript text with a role (user,agentorsystem) and a timestamp per turn. Messages your flow or bot sends arrive from Connect with roleSYSTEMand are mapped toagentby default. Everyagentturn also carriesactor:botfor SYSTEM, BOT and CUSTOM_BOT,humanfor AGENT, so ciopulse can score the AI agent without the person who took over. Consecutive voice segments from the same speaker are merged into one turn.outcome:unknown, unless you setOutcomeAttributeName.events[]: anescalation_to_humanevent when a human agent was connected to the contact, or when a second agent-role participant appears in the transcript. On a contact answered by a human from the start this event is still emitted; it means "a person handled this", not "the bot gave up".platform_signals(optional): numbers Connect already computed, forwarded as-is. Customer sentiment overall and by period, talk and non-talk time, interruption count, agent response time, and the generated contact summary. ciopulse displays these as comparators next to its own reading. They are never used as scoring inputs. The block is omitted entirely when analytics did not produce it.metadata: queue name, initiation method, forwarder version. Under 2 KB.
Never sent, never read:
- Audio. The IAM policy allows
s3:GetObjecton*.jsonunder the redacted prefixes only. The redacted.wavthat Connect writes next to each voice analysis file is not readable by this role, and the event rule never fires for it (verified). - Unredacted transcripts. The role has no access to the unredacted prefixes, and the handler refuses any key without
/Redacted/in its path even if an event for one arrives. - Phone numbers, customer names, display names, attachment names. Connect redacts these before the file is written.
- Contact attributes other than the one you name in
OutcomeAttributeName.
Never logged: transcript text, summaries, or any payload field. Logs carry the ContactId, sizes, HTTP status codes, attempt counts and timings only.
Excluded queues are sent as a stub: the required envelope, exclude: true, and a single placeholder turn. ciopulse counts the contact and discards it; no transcript text leaves your account for those.
Retries are safe. ciopulse replaces an earlier submission with the same session_id within 30 days, so a retried or duplicate delivery never double-counts.
The survey is delivered by Connect, not by ciopulse, so ciopulse never sees a phone number. The full step-by-step is in docs/survey-flow.md. In short:
- In the Disconnect flow, add
Set contact attributes→ user-defined attributesurvey_url= your ciopulse survey link withtid=$.ContactId. - Add
Send message(SMS) with a body under 1,024 characters that references$.Attributes.survey_url. - Optionally put a
Distribute by percentageblock in front to sample. Start at 100%. - For chat, put the same link in the bot's goodbye message.
An SMS-capable origination number must be claimed to the instance first. In Australia, carrier registration for that number takes weeks, so start it early.
Metrics are published to CloudWatch namespace ciopulse/ConnectForwarder with one dimension, Stack = your stack name. They are emitted through the log stream (embedded metric format), so no extra IAM permission is involved.
| Metric | Meaning |
|---|---|
Forwarded |
ciopulse returned 202 for a transcript |
Excluded |
Sent with exclude: true (queue exclusion); ciopulse returned 202 |
Failed |
Delivery gave up: three attempts on 5xx/429/network, or a non-retryable 4xx |
ParseErrors |
The S3 object could not be read as a Connect analytics file |
Skipped |
Ignored by design: not a redacted .json, or a multi-party contact with SkipMultiPartyContacts=true |
The template creates one alarm, Failed ≥ 1 in 5 minutes. Pass AlarmSnsTopicArn to have it notify you.
Each processed object writes one JSON log line. Look at status, reason and http_status:
| Symptom | Likely cause |
|---|---|
| I can see the bot's turns but not the human agent's, or a conversation seems to stop at the handoff | The contact was transferred into a flow without a Set recording and analytics behavior block, so the transferred leg was never analysed. Add the block to that flow (section 3, step 2) and run a test transfer: you should see two analysis files with different contact IDs |
| Nothing happens after a call | EventBridge is not enabled on the bucket (section 3, step 1), or analytics is not enabled on the flow, or the object landed outside the key patterns. Check the bucket for …/Analysis/Voice/Redacted/… files |
status: skipped, reason: not_redacted |
Only unredacted files are being written. Set redaction_option |
status: skipped, reason: key_pattern |
Your bucket layout differs from the default patterns. Adjust VoiceKeyPattern/ChatKeyPattern (section 9) |
http_status: 401 |
The secret does not hold a valid ciopulse key |
http_status: 400 |
Contract mismatch. The log line includes ciopulse's field-level problems |
http_status: 413 or reason: too_many_turns |
Contact exceeds 1 MB or 500 turns after merging. Rare; it is logged and dropped |
reason: contact_metadata_unavailable |
DescribeContact failed. Timestamps are estimated from the file's write time and marked timestamps_estimated: true in metadata |
The repo includes ciopulse's strict mock receiver, tools/mock-receiver/mock_ingest.py. A 202 from it means the payload really conforms to the contract; anything else comes back with field-level errors.
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements-dev.txt
pytest -q # parsers, handler, delivery, hygiene
cfn-lint template.yamlTo run the mock and post a fixture by hand:
python3 tools/mock-receiver/mock_ingest.py --key test # terminal 1, listens on :8088
sam local invoke ForwarderFunction \
--event fixtures/events/eventbridge-voice.json \
--env-vars fixtures/env.local.json # terminal 2 (needs Docker)sam local invoke still needs real AWS credentials for S3, Secrets Manager and Connect, so for a credential-free run use the unit tests, which stub those clients and drive the handler end to end against the mock.
In-account end-to-end test. tools/mock-receiver/ is the same validator wrapped as a Lambda behind a function URL, so you can prove the whole path inside your account before pointing at ciopulse:
cd tools/mock-receiver
sam deploy --guided --stack-name ciopulse-mock-receiver # asks for any MockApiKey stringPut the same string in the forwarder's Secrets Manager secret, set CiopulseEndpoint to the stack's Endpoint output, run a test contact, and watch both log groups: the forwarder logs Forwarded=1, the mock logs accepted with the turn count. Switch CiopulseEndpoint and the secret to production afterwards.
A real AI bot to test against. tools/sandbox-bot/ deploys an Amazon Lex V2 bot whose every turn is answered by a Bedrock model through a Lambda code hook (Amazon Nova Lite by default; any Bedrock model ID as a parameter). It asks the model to flag when the caller wants a person, and the accompanying flow in docs/sandbox-instance.md transfers to a queue on that flag, so you can exercise a bot-then-human contact end to end. Bot messages arrive in the transcript as SYSTEM.
The payload follows the send-a-copy contract v0.3: v0.2 plus optional turns[].actor and conversation_id. Field additions within v0.x are backwards-compatible.
- Endpoint:
POST https://app.cio-pulse.com/api/v5/ai-agent/transcripts - Auth:
X-API-Keyheader - Limits: 1 MB, 500 turns,
metadata≤ 2 KB - Response:
202with a receipt;400with field-level problems;401;413. Rate limiting is not currently applied; the forwarder still retries any429with backoff - Full text: docs/send-a-copy-spec-v0.3.md. The previous version stays at docs/send-a-copy-spec-v0.2.md.
Why S3 events via EventBridge, and not "Contact Lens events". Amazon Connect publishes no "analysis complete" event. The EventBridge entries under the aws.contact-lens source are CloudTrail API-call records with best-effort delivery, and Contact Lens rules fire on category match, not completion. S3 object-created events (aws.s3 source) are the reliable signal that a redacted analysis file exists. Using EventBridge rather than a direct S3-to-Lambda notification lets the stack attach to a bucket it does not own without touching the bucket's existing notification configuration.
Key patterns. AWS documents the voice layout (…/Analysis/Voice/Redacted/YYYY/MM/DD/<contactId>_analysis_redacted_<ts>.json) but not the chat one. On a live instance (September 2026) chat analysis landed at the bucket root, Analysis/Chat/Redacted/YYYY/MM/DD/<contactId>_analysis_redacted_<ts>.json, not under the storage prefix configured for chat transcripts. The default patterns *Analysis/<Channel>/Redacted/*.json match both the root and a connect/<alias>/… prefix, since * matches zero or more characters in EventBridge, IAM and the handler alike. The same string drives all three, so changing the parameter changes them together.
Participant roles. Voice files label speakers AGENT and CUSTOMER. Chat files carry per-participant roles: on a live instance, every message sent by the contact flow or a bot arrived as SYSTEM, with a real human agent as AGENT. CUSTOMER maps to user; anything in AgentParticipantRoles maps to agent; everything else becomes system. If your flow sends system notices you do not want attributed to the agent, remove SYSTEM from the parameter.
Kinesis. Connect's documented real-time path is a Kinesis Data Stream of analysis segments. It is not built here; src/kinesis_stub.py marks where it would attach. The S3 path is simpler, needs no stream, and post-call latency of a few minutes is fine for a survey-and-transcript join.
Chunking. The contract caps a transcript at 500 turns. Since session_id is the join key and a re-POST replaces the earlier one, splitting a contact across payloads would overwrite itself. The forwarder therefore merges consecutive same-speaker voice segments and drops anything still over the cap, with a Failed metric and a log line.
Licence: MIT. Issues and pull requests welcome; see CONTRIBUTING.md. To report a security problem, see SECURITY.md.