- Brand
- Unknown
- Category
- Pending
- Primary Subcategory
- Pending
Integration details
Description
Apsis Vega turns ChatGPT into your marketing assistant. It connects ChatGPT to your Apsis data and marketing workflows, enabling you to analyse, create, personalise and execute campaigns directly from a conversation. Apsis Vega does the heavy lifting so you can focus on the marketing strategy and creativity. What you can have ChatGPT do with Apsis Vega - Create and send emails at scale - Analyse data across campaigns and combine with other data to uncover actionable insights - Find and build audiences through segmentation - Increase relevance with personalisation from insights obtained directly from your data - Measure and follow campaign performance and extract profiles to act on - Let ChatGPT do repetitive tasks to free your time Apsis Vega works with the Apsis One accounts you can already access, with your own permissions. Sending an email and deleting a contact ask for your confirmation first. An Apsis Vega account, provided by Apsis, is required.
- Integration type
- Plugin
- Verification status
- Not applicable
- Platform
- ChatGPT
- Category
- Pending
- Primary Subcategory
- Pending
- Secondary Subcategories
- None listed
- Brand
- Unknown
- Access
- Account required
- First tracked
- 2026-11-05
- Tool count
- 50
- Market
- US
The broad Category that contains the Primary Subcategory.
The Primary Subcategory used for this profile’s headline score.
Other Subcategories where the Integration is listed.
Your score is coming
ChatGPT now suggests Plugins on its own when they match a user's request.Your Plugin Discovery Score measures how often yours appears, and it will show here as soon as it’s ready.
What discovery looks like

Get alerts for Apsis Vega
Get updates when Apsis Vega’s Discoverability Score or category rank changes.
Competitive lineup
50 tools agents can invoke
Create a new custom profile attribute (a field on the profile) in a section. Reach for it when an import column has no attribute to map onto and the user wants to keep that column — without it the only option is dropping the column. Pass `account` and `section` (ids from list_accounts / describe_import_target), the `name` the user would recognise, and a `type`. It takes two calls. The first, without `confirm`, runs the near-duplicate check and writes nothing: it returns `{ confirmed:false, willCreate:{ name, type, section, typeIsFixed, folder, similarExisting? }, confirmToken, note }`, where `note` is written for the user and says what confirming would do and that nothing has been created yet. The second, with `confirm:true` and that `confirmToken`, creates the attribute. The token is bound to the account, the section and the exact inputs, `createAnyway` included, and expires in 1 hour; a confirming call with a missing, expired or altered token is refused and creates nothing, and it repeats the near-duplicate check against the section as it is by then. A repeat confirming call with the same token is checked against the section again: what the first one created is by then a near-duplicate, so it is refused unless `createAnyway` was set, in which case it creates another one. A call from a client that does not know about `confirm` gets the first-call answer and creates nothing. This is close to irreversible: an attribute cannot be deleted, and hiding it later does not take effect in the Apsis One interface for at least an hour. Once created, its `type` is fixed, so a type that was inferred rather than given by the user is permanently what the section has. The display name is not: rename_attribute changes it, and renaming again changes it back. On a confirming call you get `{ created:true, attributeVersionId, name, type, discriminator, importReady, folder, note }`, where `folder` is the folder the attribute was filed in ("Custom Attributes"). That `attributeVersionId` maps directly into create_import's `attributeMappings` — describe_import_target does not need to look it up first, because it can take up to an hour to list a newly created attribute, and the attribute is already permitted for import. `{ created:false, reason:"similar-exists", candidates }` is a normal answer, not an error, and carries no token: an attribute that means the same thing already exists (names differing only in case, spaces, dots, dashes or underscores are treated as the same field). Mapping one of the `candidates` by its `attributeVersionId` avoids adding permanent clutter for everyone in the account; `createAnyway: true` makes the first call describe the create anyway, for a genuinely separate field, and the flag is part of what the token is bound to. `{ created:false, reason:"custom-folder-missing" }` is also a normal answer, carries no token and creates nothing: the account has no Custom Attributes folder to file the attribute in, which is a platform set-up problem that Apsis support can repair. The `type` follows what the data is, not what the file looks like: `string` for text (and for a CSV column of uncertain type), `integer`/`double` for numbers that may need comparing or sorting, `boolean` for yes/no, `One.instant` for a point in time, `One.legacy_date` for a plain calendar date. The type cannot be changed afterwards, which matters if the field may ever need segmenting on numerically or by date rather than as `string`. The confirming call's `note` carries the mapping instruction, the permanence warning, and a heads-up when the section is accumulating a lot of custom attributes.
create_attribute
Create a subscription — a thing people can opt in to, and the audience a campaign is sent to. Reach for it when the user wants to start collecting consent for something new. Pass `account` and `section`, the `name` the user would recognise, and the `folderId` it should be filed in (from list_subscriptions; create one first with create_subscription_folder if the section has none). The folder is fixed at creation: a subscription can never be moved to a different folder afterward, so the only fix for a mis-filed one is to hide it and start its consent collection over from zero. Creating a subscription at the top level is refused — it has to go in a folder. A `folderId` that was inferred rather than given by the user is permanently where the subscription lives, and an inferred `name` stays until rename_subscription changes it, which keeps the id and the consent. It takes two calls. The first, without `confirm`, reads the section's folders, runs the limit and near-duplicate checks and writes nothing: it returns `{ confirmed:false, willCreate:{ name, folder, folderId, folderIsFixed, similarExisting? }, confirmToken, note }`, where `note` is written for the user and says that nothing has been created yet. The second, with `confirm:true` and that `confirmToken`, creates the subscription. The token is bound to the account, the section and the exact inputs, `createAnyway` included, and expires in 1 hour; a confirming call with a missing, expired or altered token is refused and creates nothing, and it repeats the checks against the section as it is by then. A repeat confirming call with the same token is checked against the section again: what the first one created is by then a near-duplicate, so it is refused unless `createAnyway` was set, in which case it creates another one. A call from a client that does not know about `confirm` gets the first-call answer and creates nothing. On a confirming call you get `{ created:true, subscriptionId, name, folder, discriminator, emailConsentAttributeId?, note }`. That `subscriptionId` is usable straight away — pass it to create_import's `subscriptions` to subscribe imported profiles, or to send_email_activity as the audience, with no need to call describe_import_target or describe_email_send first to find it. Nobody is subscribed yet, so a send to it right after creation reaches nobody. `{ created:false, reason:"similar-exists", candidates }` is a normal answer, not an error, and carries no token: a subscription in that same folder already means the same thing (names differing only in case, spaces, dots, dashes or underscores count as the same), so the existing `subscriptionId` is usually what was meant — consent collected against the wrong one of two look-alikes is a data problem, not a cosmetic one. `createAnyway: true` makes the first call describe a separate one regardless, once the user has seen the existing name and confirmed they want a separate subscription, and the flag is part of what the token is bound to. `note` on each of those results is written for the user. If the section is at or past its limit of 10 subscriptions (a section built in the Apsis One interface can hold many more), the call comes back as an error rather than a result, so there is no `note` — the error text itself is written for the user and names the two things that help: using one of the existing subscriptions, or hiding enough to get back under the limit, with how many that would be. It is a fixed limit, so retrying does not change the outcome.
create_subscription
Create a folder to file subscriptions in. Reach for it when the user wants to group what they collect consent against, or when there is no folder yet to put a new subscription in. Pass `account`, `section` and the `name`. Folders cannot be deleted, only hidden. Folders do not nest: every folder sits at the section's top level, so there is no parent to choose. It takes two calls. The first, without `confirm`, reads the section's folders, runs the limit and near-duplicate checks and writes nothing: it returns `{ confirmed:false, willCreate:{ name, level, similarExisting? }, confirmToken, note }`, where `note` is written for the user and says that nothing has been created yet. The second, with `confirm:true` and that `confirmToken`, creates the folder. The token is bound to the account, the section and the exact inputs, `createAnyway` included, and expires in 1 hour; a confirming call with a missing, expired or altered token is refused and creates nothing, and it repeats the checks against the section as it is by then. A repeat confirming call with the same token is checked against the section again: what the first one created is by then a near-duplicate, so it is refused unless `createAnyway` was set, in which case it creates another one. A call from a client that does not know about `confirm` gets the first-call answer and creates nothing. On a confirming call you get `{ created:true, folderId, name, note }` — pass that `folderId` straight to create_subscription. `{ created:false, reason:"similar-exists", candidates }` carries no token and means a folder with a name that close already exists, which is usually what was meant; `createAnyway: true` makes the first call describe a separate one if the user confirms they want both, and the flag is part of what the token is bound to. A section can hold 10 folders. At the limit the call is refused and nothing is created — a fixed limit, so retrying does not change the outcome; the refusal text names what helps.
create_subscription_folder
Stop an import that is still running. Pass the `section` and `account` it belongs to (both are on every list_imports row and echoed by create_import), plus its `importId`. It takes two calls. The first, without `confirm`, reads the import's status and writes nothing: for a running import it returns `{ confirmed:false, willCancel:{ importId, section, importStatus, counts }, confirmToken, note }`, where `note` is written for the user and says that nothing has been cancelled yet. That read carries no file name; list_imports rows do. The second, with `confirm:true` and that `confirmToken`, cancels. The token is bound to the account, the section and the exact inputs and expires in 1 hour; a confirming call with a missing, expired or altered token is refused and cancels nothing. A call from a client that does not know about `confirm` gets the first-call answer and cancels nothing. This is not an undo: cancelling stops the remaining rows, every profile already imported stays imported, and the import cannot be resumed — finishing the file needs a new import. A confirming call's `note` states what actually happened and `counts` says what had already landed. For an import that is already cancelled, finished, failed or expired, the first call reports it without a token, with nothing changed; a confirming call still needs a valid token, and once verified reports the same — `status:"cancelled", alreadyCancelled:true` for a cancelled one, and `status:"not_cancelled"` with a `reason` (`already_finished`, `already_failed` or `already_expired`) and its counts for one that finished, failed or expired, rather than a retry target. If the id is unknown in that section, list_imports finds the right one.
cancel_import
Calls off a send that is booked and has not gone out, returning the email to a draft. Takes `activityId` from list_email_activities and the account it lives in (`account` is optional — omit only if the user has a single account). It takes two calls. The first, without `confirm`, writes nothing and returns `{ confirmed:false, willCancel:{ activityId, subject?, stateBefore, sendAt?:{ local, timezone }, audience:{ subscriptions:[{ subscriptionId, name }], segments? } }, note, account }`. `audience` is read from the email: subscription names come from the section's subscription listing and fall back to the id when that listing can't be read or doesn't include it, and `segments` is present only when the email carries a segment narrowing, with names where they could be resolved. `note` is written for the user and says what would be cancelled and that nothing has changed yet. The second, with `confirm:true`, cancels. Where `willCancel.sendAt` is present it also needs `sendAtLocal` and `sendAtTimezone`, the two values from it. The email is read again and the booked time recomputed, so a confirming call whose values are missing or differ from the booked time is refused with `{ activityId, cancelled:false, status:"confirmation_required", reason:"send_time_not_confirmed", note, willCancel }` and cancels nothing. An email with no booked time (its `sendType` is not a schedule) has no `sendAt`: `confirm:true` alone confirms it, and passing either field is refused. A call from a client that does not know about `confirm` gets the first-call answer and cancels nothing. The state is read afresh on the confirming call, so an email that has changed since the first call and can no longer be cancelled returns that outcome instead. Outcomes where nothing would be cancelled answer on the first call too, with no confirm step: an email that is already a draft comes back with `alreadyCancelled:true`, and a draft is never written, even one still carrying a leftover scheduled time, which stays recorded on the draft and which the `note` mentions, so calling again is harmless. It also cancels a `paused` or `unscheduled` email; `paused` usually means someone held it in Apsis One rather than that the assistant booked it. duplicate_email_activity is not an alternative route to the same outcome — duplicating a booked activity leaves the original booked, so the campaign would go out twice. A send that has already started (`queued`, `processing`, `exporting`, `sent`) is not an error: the result is `status:"not_cancelled"` with a `reason`, nothing changes, and a retry returns the same thing. The cancel window closes at the booked moment and the scheduler runs every 60 seconds, so a cancel inside the final minute is a race that can be lost. A confirming call returns { activityId, cancelled, status, alreadyCancelled?, reason?, reasonCode?, stateBefore?, stateAfter?, subject?, wasScheduledFor?, note, account }. `stateBefore` is what was cancelled and `stateAfter` is what the read-back saw: `scheduled` was a send booked through the assistant, `paused` was one held in Apsis One. On `status:"not_cancelled"`, `reasonCode` is the branchable form of `reason` — `"too-late"` (the send is already under way), `"not-cancellable"` (the activity is in a state a booked send cannot be cancelled from), or `"state-unreadable"` (the email's state could not be read at all, so it was left untouched rather than risk making a sent campaign sendable again). `note` is written for the user and states what actually happened, including where the read-back may not have caught up yet. After a cancel the email is a draft again: edit_email_activity accepts it, and it can be sent or booked for a new time, which is how rescheduling works — there is no reschedule tool.
cancel_scheduled_send
Change an existing segment's name and/or its conditions (who it targets), in place — the segment keeps its identity and everything already using it keeps pointing at it. A conditions edit does mint a new version of the segment upstream, including when the conditions you send are identical to the ones stored, so repeating an edit is not free even though the result looks unchanged. Supply at least one of `name` or `aql`; a name-only rename leaves the conditions untouched, and vice-versa. A segment is a live filter, not a snapshot: it is re-evaluated at send time, so changing its conditions also changes who any email already using it will reach, including anything scheduled but not yet sent. That is normal and supported — it is how a marketer fixes or widens an audience for a send that hasn't gone out yet. Where the marketer instead wants a different audience for a new campaign, a separate segment leaves the existing one working for whatever already uses it. A rename changes nothing about who is targeted. Conditions are replaced in full, not edited in place: the conditions passed are the complete new audience. describe_segment returns the segment's current conditions as an AQLContainer, and describe_segment_target gives the attributes, events and operators for any condition added to it. An edit is a whole-audience replacement rather than a patch: what is sent replaces what is stored, and a condition left out of it is gone. The conditions are an AQLContainer, never AQL text. The conditions are validated before persisting (a backend-invalid query is refused before anything is saved), exactly like create_segment. A conditions change is two steps. Without `confirm` nothing is saved: the result is a preview carrying the segment's current audience and the new one in plain words, the profile count of each, `changes` (`kept`, `removed` and `added`, each condition in plain words, present when the current conditions can be described) and a `confirmToken`. The save is the same call with `confirm: true` and that token. The token is bound to the segment, the name and the conditions of the preview, and it is refused when any of them differ, when it expired, or when the segment was changed after the preview (a best-effort check, since a read straight after a write can lag it). A segment whose current conditions cannot be described can still be edited: the preview then says so, gives its current size, and the edit replaces its conditions as a whole. A name-only edit is one call with no preview. This edits only name and conditions — there is no description to edit, and you cannot move a segment to another account (a segment id from a different account simply isn't found). When conditions were edited, the result carries an `audienceDescription` — a plain-language rendering of the new conditions, already in the marketer's own words, and re-describing the conditions instead throws that rendering away. A name-only edit returns no `audienceDescription`, since conditions didn't change, and it can also be absent on a conditions edit whenever it could not be rendered, which leaves plain words of the caller's own. This tool's result is for relaying to a non-technical marketer: what reaches them, whether it is `audienceDescription` verbatim or wording of your own, contains no operators, parentheses, comparison symbols (>, <, =, ≠, !=), field types or precedence rules. Returns the segment's id (unchanged).
edit_segment
Edit an existing draft email activity in place — change its subject, its preheader (inbox preview text) and/or its full HTML body (and, when you resend the html, the link click-tracking) — instead of creating a new activity. Pass `activityId` (from create_email_activity) and the `account` it lives in (`account` is optional — omit only if the user has a single account; with several, omitting is refused). Supply at least one of `subject`, `preheader`, `html`, or `trackLinks`; only the fields you pass are changed. A preheader edit is only possible on an email the assistant created (the preheader lives in the email's content, same as the html) — refused with a plain explanation on an Apsis One-authored email, where only the subject can be changed. Removing a preheader is not supported yet — a blank value is refused rather than guessed at, so leave the field out to keep the current one. `html` is a full-body replacement: send the complete new HTML (exactly as for create_email_activity — links are re-wired automatically, and the View-in-browser | Unsubscribe footer is re-added), it does not patch part of the body. Re-wiring replaces the activity's links rather than editing them: new ones are created and the previous ones removed, so link ids change on every html edit. `trackLinks` only takes effect together with `html` (resend the html to change tracking); passing it alone is refused. At most 100 web-address/tracked links per email (the same URL repeated in several anchors counts once per occurrence, not once overall) — an html carrying more is refused naming the count found. Only drafts can be edited — a scheduled/sent activity is refused. A draft cannot be deleted here; an unwanted one can be renamed through its subject and left unsent. The check reads the activity's state, so an activity whose response carries no state at all is not refused; do not rely on this as a guarantee that a live campaign cannot be reached. If the email has rich/multi-block content built in the Apsis UI (not a single assistant-authored HTML block), the html edit is refused so it isn't flattened. Editing changes the content, so any earlier preview_email_activity or send_test_email_activity review is invalidated: a send needs a fresh `reviewToken`, which only a new preview or test-send mints. Returns { notApplied?, activityId, account, section, edited, trackLinks?, orphanLinksNote?, links?, previewUrl?, previewNote?, images?, imagesNote? } where `edited` lists the fields believed to have been applied — subject and preheader are read back after writing rather than assumed from a successful write, but that read-back fails open: if it cannot be performed the field is still listed, so `edited` means "verified, or unverifiable", not "verified". `notApplied` is present only when a subject or preheader change was requested but read back as unchanged — each entry is `{ field, reason }` with `reason` written for the user and naming its own remedy, which is never a retry: the same call already failed silently once. On an HTML edit, `images` reports where the new body's images can be reached from (`{ remoteHosts, expiring, unreachable }`, with `remoteHosts` also listing stylesheet-link and style `url()` hosts, which `styleHosts` marks as ones Apsis cannot host) — use it to confirm a hosted-URL swap actually landed, and if `remoteHosts` is still non-empty, `imagesNote` is the offer to host the rest, written for the user; on a yes, hosting is `host_email_images_from_url` followed by one more `edit_email_activity`. `orphanLinksNote` is informational, not a fault: cleanup of the previous body's old links can be cut short by the call running out of time, or an individual delete can fail on its own — either way those old links are harmless and unreferenced by the new body, so no action is needed. A metadata-only edit — subject and/or preheader — reads no body, so it reports neither. `previewUrl` shows the draft as it now stands, which is what someone who has just changed their email is looking for; it comes back on every edit without having to be asked for. Where a preview host is configured but the link could not be made just now, `previewNote` is present in its place, and the edit itself has been applied: preview_email_activity returns the link. (Sending still needs a fresh `reviewToken` from preview_email_activity or send_test_email_activity; this link alone does not satisfy the send gate.)
edit_email_activity
Report an import's current status and counts — `total` rows found, `successful` imported, `unsuccessful` failed, `updated`, `skipped` — and follow it to completion unless told not to. Pass the `section` and `account` the import was created in (both echoed by create_import) — carry them across your calls; omit `account` only if the user has a single account. `status` is one of `uploading`, `queued`, `parsing`, `processing`, `ready`, `failed`, `cancelled`, `expired`: `uploading` until the file lands in S3, then `queued`/`parsing`/`processing`, ending at `ready` (or `failed`/`cancelled`/`expired`). The result echoes the resolved `account`/`section` alongside the counts, plus `awaitingUpload`, `timedOut` and `waitedSeconds`. When rows were skipped the result carries a `note` stating what that means: `skipped` on its own reads like a failure when it usually is not — skipped rows are people already in the account that the import was set to leave alone, and/or rows with no usable email address, and with updating switched off a single count can cover both. This call reports counts, not rows: when `unsuccessful` or `skipped` is non-zero and which rows and why matters — a malformed address reads exactly like a locked profile in a count — get_import_report (once the import has finished) has the row-level breakdown. A `statusReason` on a failed import is upstream's own explanation. When the reason names missing columns the accompanying `note` matters more than the raw text: nothing at all was imported, because a column name in the mapping does not match the file's heading exactly. If the import used a per-row subscribing column, these counts say nothing about whether anyone was actually subscribed: a row that received no consent is still counted as imported. Passing back the `subscribedBefore` array create_import returned adds a `subscribing` array once the status is `ready` — one entry per subscription with `before`/`after`/`delta` counts and a `verdict`. There is no verdict on a `failed`, `cancelled` or `expired` import. That is not the same as nothing having been imported: a cancel stops the remaining rows and keeps the ones already written, which `successful` counts, so a cancelled import's subscribing may have partly happened and is unconfirmed either way. `increased` means the after-count is strictly higher than the before-count, which is consistent with the subscribing having worked, but it is not proof it did — anything else changing consent on that subscription in the same window (another import, an API caller, a person opting in through a form) moves the same number, so it is evidence rather than proof. `inconclusive` means the delta is zero or negative, which is not a failure report: the subscriber count can lag the import by a few minutes, so it reads as too early to tell rather than as the subscribing having failed. Omitting `subscribedBefore` leaves the result with no `subscribing` at all, which the `note` states rather than leaving silent. create_import does not always have a baseline to give: when its own count was unavailable it returns a `note` instead, and count_subscribed is then the manual equivalent of the same comparison. This check is itself a diagnostic under a fixed ~10s budget and never holds the call open for it — when the count cannot be read in that time the result carries no `subscribing`, and the `note` says the check was unavailable, distinct from `inconclusive`. Calling this immediately after create_import returns at once with `awaitingUpload:true` while the file has not arrived, and blocks only once the file is being processed — up to ~45s of waiting plus at most ~10s for the subscribing check, returning as soon as a terminal status is reached. `timedOut:true` means the import is still processing. `awaitingUpload:true` means the user has not opened the upload link yet, which is not a failed import. Pass `wait:false` for an immediate snapshot instead. If the `importId` is not at hand — an earlier conversation, or context no longer held — list_imports finds it without guessing or asking for an id that was never shown.
get_import_status
Read-only pre-flight for an email activity: reports whether it can be sent. Returns { canSend, exportValid, links: { unresolved, sample }, rulebook: { valid, warnings }, images, account }. `canSend` is true only when `exportValid` is (the hard gate — the audience/export must resolve to sendable profiles) and `links.unresolved` is 0: `links` counts links in the email that resolve to nothing (typically a copy whose links were not copied with it), with up to five of their ids in `sample`, and send_email_activity refuses such an email outright — there is no acknowledgement for it. Rulebook messages such as "Missing unsubscribe link" are surfaced as overridable warnings, not blockers. `images` says where the images in the content that would send can be reached from: `remoteHosts` are hosts Apsis does not control (Apsis can host them: `host_email_images_from_url` takes the whole set in one call, then one `edit_email_activity` swaps the `src`s; stylesheet-link and style `url()` hosts are listed too, marked in `styleHosts`, and those cannot be hosted), `expiring` are URLs that will stop working (these do gate a send), and `unreachable` are sources only the author can see. Image findings never change `canSend` — a customer's own CDN is a perfectly good host, so `remoteHosts` is a suggestion, never a blocker. Note: a missing unsubscribe link is GDPR-critical — send_email_activity will require an explicit second re-confirmation before it ships such a campaign. Mutates nothing. Pass the `account` the activity lives in (`account` is optional — omit only if the user has a single account; with several, omitting is refused). Note: a freshly created draft has no audience (subscriptions) yet, so `canSend` is typically false until an audience is set.
validate_email_activity
Returns the conventions that apply to every Apsis Vega conversation: how an audience is described to a marketer, how its conditions are agreed before anything is built, how a request that can't be done here is answered, how accounts, segments and emails are named, and how the company's name is written: Apsis, a capital A followed by lower case, with the products Apsis One and Apsis Vega. Reads nothing from any account and changes nothing.
get_conversation_guide
Duplicate an existing email activity into a new draft (the platform copies the content server-side, so the `##…##` personalization is preserved exactly — unlike rebuilding from a preview). An activity can only be sent once, so a copy is how the same content goes out again, and how an existing email becomes the template for a new one. Pass `activityId` (from list_email_activities / create_email_activity) and the `account` it lives in (`account` is optional — omit only if the user has a single account). Optional `name` renames the copy; omit it and the copy is named "<original> (clone)". Renaming is a second write after the copy exists, so if this tool errors having been given a `name`, do not assume nothing was created — the copy may exist under the default "(clone)" name; list the section's emails before duplicating again. Any source state can be duplicated (a sent one included). It takes two calls. The first, without `confirm`, reads the source email and writes nothing: it returns `{ confirmed:false, willCreate:{ source:{ activityId, name, state, sentAt? }, name, audience, from?, folder? }, confirmToken, note }`. The second, with `confirm:true` and that `confirmToken`, makes the copy and returns the shape below. The token is bound to the account and the exact inputs and expires in 1 hour; a missing, expired or altered token is refused and nothing is copied. A repeat confirming call with the same token makes another copy. A call from a client that does not know about `confirm` gets the first-call answer and copies nothing. On the confirming call it returns { folder?:{ filed, location? | note? }, linkIntegrity:{ ok, unresolvedLinks, sample?, note? }, activityId, name?, audience:{subscriptionIds,segments?}, from?, account } — `folder` is present only on an account that uses folders, where the Drafts list and search show only emails filed in a folder: `filed:true` means the copy is in the main Drafts list (`location:"root"`), which is not next to the original when the original sits in a subfolder; `filed:false` means the copy exists but may not show in Drafts or search yet, so a second duplicate would only be a second copy, and `note` says so, written for the user. `from` is absent when the source carried no sender, and a send without one is refused, so the copy needs a sender supplied before it can go out. `linkIntegrity` says whether every link in the copy resolves: `ok:true` is a healthy copy; `ok:false` means some links (possibly the unsubscribe link) point nowhere, so the copy cannot be sent — send_email_activity refuses it — and `note` is the remedy, written for the user; `ok:null` means the check could not run, and send re-checks. The copy arrives pre-aimed at the same audience — subscriptions and any segment narrowing (the backend clone copies `segmentation` too) — and sender as the original, echoed in `audience`/`from`. The copy carries that audience and sender only as stored values: send_email_activity writes its own `from`, `subscriptionIds` and `segments` over them, so a send of the copy without the same `segments` goes to the whole subscription, not the narrowed audience. `audience` and `from` here are the values that reproduce the original's aim, and the send review-token does not cover the audience. The copy is a fresh activity: it must be previewed/test-sent on its own to get a new reviewToken (a token from the original is refused as wrong-target). The copy inherits the original's assistant-authored status — a duplicate of an assistant-created email stays assistant-editable; a duplicate of an Apsis One email does not.
duplicate_email_activity
Count how many profiles in a section are currently subscribed to one subscription — opted in or double opted in. An import's own counts say nothing about whether consent was created — a row that got no consent is still reported as imported — so a subscribing import is only verifiable against this count. In the import flow that comparison is not this tool's job: create_import captures the before-count itself and returns it as `subscribedBefore`, and get_import_status does the after-count and reports a `subscribing` verdict from it. This tool is the manual fallback for the case where create_import could not read the count and said so in its `note`: calling it once before the upload and once after gives the same comparison. This number lags recent changes by a few minutes — it is not live. Straight after an import it can still show the old figure even though the subscribing worked, so an unchanged number reads as too early to tell — never proof that nothing happened. Nothing here can tell a count that is merely lagging from one that will never move: the platform reports no time at which an import finished, so there is no interval after which an unchanged count becomes evidence of failure. The number counts everyone subscribed to that subscription, not just the people in a given file, which is why the difference between two readings is the meaningful figure rather than either reading alone — and even the difference is evidence rather than proof, since anything else changing consent at the same time moves it too. Also usable on its own to answer "how many people are subscribed to X": the result carries a subscription and a count of people, and nothing else — no consent state, no attributes, no query — because that is all it is built from. On success the result is `{ subscriptionId, subscriptionName, profileCount, account, section }`. If it cannot count — an unknown subscription, or one that exposes no consent data — the call fails with an error rather than returning a number; that error never means nobody is subscribed. Two input requirements are stricter here than elsewhere on this surface: `section` must be the numeric section id, not the discriminator most other tools accept, and `subscriptionId` must be digits only.
count_subscribed
Creates a draft bulk email from custom HTML, saved as a single raw-HTML element. The draft is not sent. The call requires the `planToken` returned by plan_email_activity for the same account, `section`, `subject`, `preheader`, `html` and `trackLinks`; a call without a valid one is refused and creates nothing. Each call with a valid token creates another draft. The subject and the preheader are the user's choice, and both stay editable on the draft afterwards through edit_email_activity. Returns `{ activityId, account, section, trackLinks, links, previewUrl?, previewNote?, images, imagesNote? }`, where `links` summarizes the wired web-address links as `{ id, url, tracked, hasDataTags }`, plus a `note` when the draft could not be filed into its folder. `images` reports where the email's images can be reached from, as `{ remoteHosts, expiring, unreachable }`. `remoteHosts` lists hosts Apsis does not control, including the hosts of stylesheet links and of `url()`s in styles (`styleHosts` names the ones only styling reaches, which Apsis cannot host), and none of them blocks a send. When it is non-empty, `imagesNote` carries the offer to host them, written for the user. Hosting is host_email_images_from_url followed by one edit_email_activity to swap the `src`s, and doing that on the draft is cheaper than at send time, when the same edit invalidates the review token. `previewUrl` links to the draft as built. It is absent when the environment has no preview host configured, a case preview_email_activity handles explicitly. Where a preview host is configured but the link could not be made just now, `previewNote` is present in its place: the draft is saved either way, and preview_email_activity returns the link. It is not a review for sending: the `reviewToken` a send requires comes from preview_email_activity or send_test_email_activity.
create_email_activity
Permanently erase one profile and all its data. Pass `account` (from list_accounts), `section` (from list_sections, or omit if the account has a single one), and `identifier` — the whole unique identifier value, exactly as the person gave it (typically a full email address). Two steps. Called without `confirm`, it deletes nothing and returns `{ outcome:"resolved", attributes, irreversible, accountWide, deleteToken, … }`; the `attributes` are there because they are the only way to tell whether this is the right person. Calling again with `confirm:true` and the `deleteToken`, repeating the same `identifier` and `section`, performs the deletion. Three facts about a deletion are invisible from the user's side. It is irreversible. It removes the person from the whole account rather than just this section, which is what a `warning` field marks. And where `crmTied` is true, `crmWarning` explains that the profile has a CRM id, so the next CRM update recreates it unless the record is anonymised in the CRM first — without that, the deletion does not stick. A request to stop receiving email is not a deletion request, and erasing someone is not a way to satisfy one. Erasure is not a side effect of another task either — it is only ever the task itself. One profile per call: `identifier` is a single value and there is no batch form, so a request like "here are 30 addresses, clean this up" resolves into one call per person, each with its own confirmation. `outcome` is the discriminator. `resolved` means nothing has happened yet. `deleted` means it is done. `refused` means nothing was deleted and carries a `reasonCode` plus a `message`, which is written for the user. Two reason codes carry more than the message. `reasonCode:"ambiguous"` means the identifier matches more than one person and nothing can be deleted from it; there is no way to pick a candidate here, so only a narrower identifier resolves it. `reasonCode:"not-in-section"` means they exist but have no data in this section, so there is nothing to show that would confirm identity, and a section where they do have data is where a lookup succeeds.
delete_profile
Find one profile in a section by a complete unique identifier (e.g. a full email address). Pass `account` (from list_accounts), `section` (from list_sections, or omit if the account has a single one), and `identifier` — the whole value, exactly as the person gave it. There is no partial or substring match: a value that only partly matches comes back as `not-found`, never a wider search. This is exact-match lookup only — it does not show every attribute, subscription or consent event for a profile; that is a separate capability. Read the `outcome` field first. `found` gives the section's default attributes (labelled by name) and a `profileRef`: the profile's reference, normally its export key (the id in the profile's address in Apsis One) and, for a profile that has none yet, the identifier that was looked up. It is carried verbatim into any later tool that acts on this profile. `ambiguous` means the identifier matches more than one person, and `mustAskUser` is true. Nothing here identifies which candidate was meant: they matched the same identifier equally, so an apparently likelier one is an appearance rather than evidence. Each candidate carries its own attributes and `profileRef`. `not-found` means no profile matches. `not-in-section` means the person exists but has no data in the section you asked about — not that they don't exist. `locked` is reported as transient, not as a not-found. `attributes` is an optional list of up to 10 attribute ids, as `describe_segment_target` lists them for the same section; the result then also carries each requested attribute that has a value, labelled by name, and `requestedAttributes` lists what was asked for, so a requested attribute missing from `attributes` has no value for that person. Those values are the person's personal data.
lookup_profile
Get an upload link for an image the user wants in an email, so it can be hosted by Apsis and referenced by `<img src="...">`. Nothing is uploaded from here: the link goes to the user, they pick the file, and their own browser sends it straight to storage — the bytes never pass through this server, the model's context or its sandbox. Pass `account` (from list_accounts) to choose where to host it — carry it across your calls; omit only if the user has a single account. `content_type` must be the image's MIME type (image/png, image/jpeg, or image/gif). `label` is a short purpose ("header banner") that names the link, so several links in one message stay distinguishable. `origin` is required and says who supplied the image — "user" if they already have the file, "web" if you found it online (then `source_url` is required too, so the link message can tell them where to download it), "generated" if you produced it, including a file you converted or resized from their original. Returns `{ upload_id, uploadLink, expiresInSeconds, account }`. Sequence: create_email_image_upload → the user uploads → finalize_email_image_upload. The upload is the user's to perform: the link goes to them, they pick the file, and their own browser sends it straight to storage, so the bytes never pass through this server, the model's context or its sandbox. Only the final message of a turn is reliably visible. Chat clients collapse a turn's intermediate steps behind a summary ("Worked for 48s"), so anything said in an earlier message of the same turn is likely never read — which is why a conversion that was performed, which file belongs in which slot, and any question still outstanding all belong beside the link rather than before it. Supported formats are PNG, JPEG and GIF. Each upload slot is signed for one type and cannot be changed once minted, so the format has to be settled before a link exists. Emails need those three anyway: WebP and AVIF do not render in Outlook. `content_type` follows the file name: .png is image/png, .jpg and .jpeg are image/jpeg, .gif is image/gif. The unsupported formats are .webp, .avif, .heic, .bmp, .tiff and .svg. A link signed for image/jpeg carrying WebP bytes is accepted by storage and then refused at finalize, which costs the user the whole trip. Converting such a file to PNG or JPEG is local work and needs no network. The disguised case matters as much: a file named .jpg can still hold WebP bytes, which is what saving from Google Images produces, and the upload page checks the bytes, so a format mismatch it reports is authoritative over the file name. The size limit is 5 MB, enforced by the signed upload policy rather than by this server, so an over-size file cannot be uploaded at all and storage refuses it with an error the user cannot act on. A phone photo is usually over it. Emails also load faster with smaller images, so around 1200px wide is a sensible target. For several local images, create_email_image_uploads presigns the whole set and returns one link, so the user visits a single page and picks every file there. Pick order is what pairs the files to the slots, and the link message spells that order out, so `images` goes in the order the files should be picked. One link per image is the fallback where that tool is unavailable. The bytes are not needed. A copy attached to the chat can be read for alt text, but an attachment is not the upload — only the link uploads. An attachment says nothing about the format. What is shown is a picture, not the file the user will pick: its real bytes and its name are both invisible, so seeing an image is not evidence that it is a PNG or a JPEG. Attachments in a chat are frequently WebP, which is the case that most often ends in a refused upload. What resolves it is the format each file actually is, or the format each slot was signed for, stated in the same message as the link, where the user can correct it before uploading. A web image does not need uploading: any `http(s)` image is accepted as it stands, so it can go into the email with its own URL and give the user a finished, previewable email immediately. Hosting it at Apsis is an improvement rather than a precondition, and the reason is that Apsis cannot control another site — the image may be blocked or changed after the send, and each open tells that site who the recipients are. Plain `http` is accepted here, but many mail clients block or warn on it, so an `http`-only image is worth hosting rather than merely offering to host. The link message already contains the ordered list with each slot's format, so restating it in other words can only contradict or lose it. (Observed: an assistant-written "choose these in this order: , , , ." that rendered four commas and no names.) What it cannot know is the part worth adding — which converted file goes with which slot. A slot carrying neither a `label` nor a `source_url` asks the user to find a file they cannot identify. Where the image was the model's choice — found on the web, or generated — the user has nothing to pick until they can get it, and `source_url` on that slot is what puts the download location into the link message. A generated image still cannot be uploaded by the model. It has to reach somewhere the user can get at — a path they can open, on a client running on their own machine, or a download, on a hosted one — and then an upload link for that. A sandbox path, a `file://` URL or a `data:` URI in the HTML renders broken for every recipient. Finalizing is one call to finalize_email_image_uploads with every `upload_id` for a set, or finalize_email_image_upload for a single image. Every `upload_id` is already held once the create has returned, so calling create again mints a second link, sends the user back to the browser to upload the same files twice, and orphans the first set. finalize_email_image_uploads finalizes every id even where one of them fails, and reports `finalized` and `failed` counts, so one bad image does not cost the user the good ones. A finalize is worth retrying only on not-found, once, in the same turn, because a missing object cannot be told apart from one that never landed. A format or size refusal fails identically on a second call, the bytes being unchanged. On not-found twice, what the upload page said — "File uploaded" or not — is what separates the two cases, and a fresh link is the way forward. An expired link has no refresh: create_email_image_upload again. Once finalize returns `imageUrl`, that URL goes into an `<img src="...">` in the HTML passed to create_email_activity, which stores it verbatim.
create_email_image_upload
Get one upload link for several images the user wants in an email — use this instead of calling create_email_image_upload repeatedly, so the user makes one trip to their browser rather than one per file. You upload nothing: the link goes to the user, they pick all the files on one page, and their browser sends them straight to storage. `images` is an ordered list of `{ content_type, origin, label?, source_url? }` — **the order matters**: the page pairs the files the user picks with these slots in order, and shows each slot's label and expected type so a mis-pick is visible before anything is sent. Give every image a short `label` ("header banner", "logo"): the link message already lists the slots in order with their labels, so a second list in other words can only contradict or lose it. At most 10 images per link — ask for a second link beyond that. Returns `{ uploads:[{ upload_id, label }], uploadLink, expiresInSeconds, account }`. After the user says they are done, call finalize_email_image_uploads once with every `upload_id` — not finalize_email_image_upload per id, which is what let one bad image strand the rest. The upload is the user's to perform: the link goes to them, they pick the file, and their own browser sends it straight to storage, so the bytes never pass through this server, the model's context or its sandbox. Only the final message of a turn is reliably visible. Chat clients collapse a turn's intermediate steps behind a summary ("Worked for 48s"), so anything said in an earlier message of the same turn is likely never read — which is why a conversion that was performed, which file belongs in which slot, and any question still outstanding all belong beside the link rather than before it. Supported formats are PNG, JPEG and GIF. Each upload slot is signed for one type and cannot be changed once minted, so the format has to be settled before a link exists. Emails need those three anyway: WebP and AVIF do not render in Outlook. `content_type` follows the file name: .png is image/png, .jpg and .jpeg are image/jpeg, .gif is image/gif. The unsupported formats are .webp, .avif, .heic, .bmp, .tiff and .svg. A link signed for image/jpeg carrying WebP bytes is accepted by storage and then refused at finalize, which costs the user the whole trip. Converting such a file to PNG or JPEG is local work and needs no network. The disguised case matters as much: a file named .jpg can still hold WebP bytes, which is what saving from Google Images produces, and the upload page checks the bytes, so a format mismatch it reports is authoritative over the file name. The size limit is 5 MB, enforced by the signed upload policy rather than by this server, so an over-size file cannot be uploaded at all and storage refuses it with an error the user cannot act on. A phone photo is usually over it. Emails also load faster with smaller images, so around 1200px wide is a sensible target. For several local images, create_email_image_uploads presigns the whole set and returns one link, so the user visits a single page and picks every file there. Pick order is what pairs the files to the slots, and the link message spells that order out, so `images` goes in the order the files should be picked. One link per image is the fallback where that tool is unavailable. The bytes are not needed. A copy attached to the chat can be read for alt text, but an attachment is not the upload — only the link uploads. An attachment says nothing about the format. What is shown is a picture, not the file the user will pick: its real bytes and its name are both invisible, so seeing an image is not evidence that it is a PNG or a JPEG. Attachments in a chat are frequently WebP, which is the case that most often ends in a refused upload. What resolves it is the format each file actually is, or the format each slot was signed for, stated in the same message as the link, where the user can correct it before uploading. A web image does not need uploading: any `http(s)` image is accepted as it stands, so it can go into the email with its own URL and give the user a finished, previewable email immediately. Hosting it at Apsis is an improvement rather than a precondition, and the reason is that Apsis cannot control another site — the image may be blocked or changed after the send, and each open tells that site who the recipients are. Plain `http` is accepted here, but many mail clients block or warn on it, so an `http`-only image is worth hosting rather than merely offering to host. The link message already contains the ordered list with each slot's format, so restating it in other words can only contradict or lose it. (Observed: an assistant-written "choose these in this order: , , , ." that rendered four commas and no names.) What it cannot know is the part worth adding — which converted file goes with which slot. A slot carrying neither a `label` nor a `source_url` asks the user to find a file they cannot identify. Where the image was the model's choice — found on the web, or generated — the user has nothing to pick until they can get it, and `source_url` on that slot is what puts the download location into the link message. A generated image still cannot be uploaded by the model. It has to reach somewhere the user can get at — a path they can open, on a client running on their own machine, or a download, on a hosted one — and then an upload link for that. A sandbox path, a `file://` URL or a `data:` URI in the HTML renders broken for every recipient. Finalizing is one call to finalize_email_image_uploads with every `upload_id` for a set, or finalize_email_image_upload for a single image. Every `upload_id` is already held once the create has returned, so calling create again mints a second link, sends the user back to the browser to upload the same files twice, and orphans the first set. finalize_email_image_uploads finalizes every id even where one of them fails, and reports `finalized` and `failed` counts, so one bad image does not cost the user the good ones. A finalize is worth retrying only on not-found, once, in the same turn, because a missing object cannot be told apart from one that never landed. A format or size refusal fails identically on a second call, the bytes being unchanged. On not-found twice, what the upload page said — "File uploaded" or not — is what separates the two cases, and a fresh link is the way forward. An expired link has no refresh: create_email_image_upload again. Once finalize returns `imageUrl`, that URL goes into an `<img src="...">` in the HTML passed to create_email_activity, which stores it verbatim.
create_email_image_uploads
Hide a folder and every subscription filed in it. Neither the folder nor the subscriptions it takes with it can be brought back afterward, and that is why this takes two calls. Pass `account`, `section` and the `folderId`. Calling it without `acknowledgeSubscriptionsHidden` hides nothing and returns `{ hidden:false, requiresAcknowledgement:true, folder, wouldAlsoHide:[names], acknowledgeSubscriptionsHidden:N, note }` — the folder by name and every subscription that would go with it, so that can be put to the user before anything disappears. Passing `acknowledgeSubscriptionsHidden` set to that `N` on a second call is what actually hides the folder. The value checked is only the count, so a count obtained from anywhere else — list_subscriptions reports it too — satisfies the second call just as well. Nothing on the server distinguishes a first call carrying `N` from a second one: the two-call shape exists so that `wouldAlsoHide` reaches the user by name before the folder goes, and it is the only thing that can. That number is checked against the folder's contents at the moment of the second call, not accepted from what the first call reported: if a subscription was added or removed in between, the value no longer matches, the call is refused, and a fresh list comes back to work from. No consent data is deleted: everyone stays opted in and campaigns already pointing at those subscriptions keep working. But none of them will ever be offered again for a send, an import or a count, and there is no way back. If the user only wants the folder tidied rather than its contents gone, hiding the individual subscriptions is recoverable through Apsis support, where this is not. On success you get `{ hidden:true, folderId, folder, alsoHidden:[names], note }`, written for the user.
hide_subscription_folder
Hide a custom attribute so it is no longer offered as a field. This is the only way to remove an attribute — an attribute can never be deleted. Pass `account` and `section` (ids from list_accounts / describe_import_target) and the `attributeVersionId` to hide. It takes two calls. The first, without `confirm`, reads the attribute and writes nothing: it returns `{ confirmed:false, willHide:{ attributeVersionId, name, section, hiddenFromApsisOneAfterMinutes, canBeUnhidden:false }, confirmToken, note }`, where `hiddenFromApsisOneAfterMinutes` is how long after the confirming call the Apsis One interface stops showing it (a duration rather than a clock time, since the confirming call can come up to an hour after the first and sets its own time), and `note` is written for the user and says that nothing has been hidden yet. The second, with `confirm:true` and that `confirmToken`, hides it. The token is bound to the account, the section and the exact inputs and expires in 1 hour; a confirming call with a missing, expired or altered token is refused and hides nothing. A call from a client that does not know about `confirm` gets the first-call answer and hides nothing. For an attribute that is already hidden, the first call reports `alreadyHidden:true` without a token; a confirming call still needs a valid token. No profile data is deleted by this — the values already stored stay exactly as they are — and there is no un-hide. Hiding is a scheduled deprecation, not an instant one, and the result's `note` states both halves of it. Once this returns, the attribute stops being offered: describe_import_target, describe_segment_target and list_email_data_tags all filter it out at source, so it cannot be picked for a new import, segment or data tag — though those three read through a per-section cache that lives up to an hour, so a process holding a warm copy can still list the attribute until its copy expires. Until `hiddenFromApsisOneAt`, about an hour later, the Apsis One interface keeps showing it (the platform enforces that one-hour minimum, and it can't be shortened here). create_import validates no mapping at all, so it accepts the attribute's `attributeVersionId` both before and after that timestamp; what changes at the timestamp is that profile writes to that column start being rejected upstream. An import already created that maps this attribute keeps writing to it until that timestamp, then starts failing those columns. Segments already built on the attribute keep working. On a confirming call you get `{ hidden:true, alreadyHidden, attributeVersionId, name, discriminator, deprecatedAt, hiddenFromApsisOneAt, note }`. Hiding an attribute that is already hidden reports `alreadyHidden:true` with the original time and changes nothing — it is not a second hide. A standard platform attribute like First Name is refused outright, which the `note` states.
hide_attribute
Hide a subscription so it is no longer offered. This is the only way to remove one — a subscription is never deleted — so reach for it when the user wants one gone. Pass `account`, `section` and the `subscriptionId`. It takes two calls. The first, without `confirm`, reads the section's folders and writes nothing: it returns `{ confirmed:false, willHide:{ subscriptionId, name, folder?, canBeUnhidden:false }, confirmToken, note }`, where `note` is written for the user and says that nothing has been hidden yet. The second, with `confirm:true` and that `confirmToken`, hides it. The token is bound to the account, the section and the exact inputs and expires in 1 hour; a confirming call with a missing, expired or altered token is refused and hides nothing. A call from a client that does not know about `confirm` gets the first-call answer and hides nothing. No consent data is deleted: everyone who opted in stays opted in, and campaigns already pointing at it keep working. Once confirmed, it stops being offered right away — for sends, for subscribing an import, for consent counts and in list_subscriptions. It can't be un-hidden here: bringing it back is a request to Apsis support, so from here it is one-way. Confirmed hiding takes effect immediately, unlike hiding an attribute, which has an in-between hour where it is still usable — hide_subscription has no such window. A campaign already pointing at it keeps working, but a send or an import not yet started can no longer pick it up, so work in progress that was going to use this subscription is worth finishing first. On a confirming call you get `{ hidden:true, subscriptionId, name, folder?, note }` — `folder` is absent for a subscription that was not in one. Calling it a second time does not report `alreadyHidden`: the subscription is no longer in the section's listing, so the call fails saying it isn't a subscription here, which for a subscription just hidden means it worked. `note` is written for the user and states that the subscription is hidden, not deleted — "hidden" and "deleted" are different things, and the difference matters to anyone asking where their consent data went.
hide_subscription
Host images that currently live on someone else's website at Apsis instead, by URL — this is how an accepted offer to host their web images is carried out, and it needs nothing from them. Unlike create_email_image_upload there is no upload link and no trip to the browser: Apsis fetches each URL itself, server-side. Pass every image in one call as `urls` (the whole set the user agreed to), plus the same `account` you have been carrying (omit only if the user has a single account). At most 10 URLs per call. Returns `{ unhostableExpiring?, stayedRemote?, note?, results:[{ index, url, ok, hostedUrl?, width?, height?, error?, retryable?, notAttempted?, formatUnsupported?, local? }], hosted, failed, notAttempted?, insufficientTimeRemaining?, remainingMs?, account }`. `formatUnsupported` marks the specific benign failure that already counts toward `stayedRemote`/`unhostableExpiring`; `local` marks a result refused before the gallery was ever called (a `data:`/local path, or embedded credentials) — its `url` is a redacted echo, not the real one. **Pair each result to its input by `index`** — the position in the `urls` array you sent. Do not pair by `url`: the same URL can legitimately appear twice in one call (a logo used in two places), and for an input that could never be hosted (a `data:` URI, or one carrying credentials) `url` comes back reduced rather than verbatim. Results are in input order too, so `index` and position agree. Each image is hosted independently: one failure never costs the others, and it is a tool error only when nothing was hosted and at least one failure is something other than a plain format refusal — retryable, or a URL/environment problem of its own (a batch cut short by the time budget before any image was even attempted is not an error by itself). On a very large or slow batch the call can run out of time before every URL is tried — that shows up as `notAttempted` results (`retryable: true`) plus top-level `notAttempted`/`insufficientTimeRemaining: true`/`remainingMs` (the last of which can still be a small positive number — not enough for one more image, not necessarily zero), and is not a failure of those images: call again with just the `notAttempted` URLs to finish the set. Sequence: host the set, then make one edit_email_activity call replacing each old `src` with its `hostedUrl` (send the full HTML, as edit always requires). Expect that edit to invalidate any `reviewToken` you hold and to need a fresh preview_email_activity before sending — that is how editing works, not a fault, so just do it rather than reporting it as a problem. An image that cannot be hosted leaves the email intact. Apsis hosts png/jpeg/gif only, and most images on the web are WebP, so a refusal here is common and expected — not a blocker, and a batch that fails this way for every image is not a tool error either. A remote image sends perfectly well; leaving it where it is remains a legitimate choice, and the only image problem that ever blocks a send is an expiring link. `stayedRemote`/`unhostableExpiring` name exactly which of the failed images fall into which bucket, and `note` is written for the user and says that more accurately than a paraphrase can. Beyond one retry when its result is `retryable:true`, an `unhostableExpiring` image has two routes: create_email_image_upload takes a saved copy in place of the link, and send_email_activity's `acknowledgeExpiringImages` takes its URL when the user chooses to send it as it is. A format refusal carries `retryable:false`, meaning a repeat of the call fails the same way; only `retryable:true` is worth one retry. The URL must be publicly reachable, because Apsis fetches it from its own network: anything behind a login, on the user's own machine, in your sandbox, or a `data:`/`file:` URL cannot be hosted this way — use create_email_image_upload for those. Never put credentials in the URL (`https://user:pass@host/…`): that is refused outright rather than forwarded to the far host, so strip them if the image is public, or use the upload route if it genuinely needs a login.
host_email_images_from_url
Plans a draft bulk email from custom HTML and creates nothing. The result is `{ willCreate, planToken, note }`, and create_email_activity creates the draft only from that `planToken`. The token is bound to the account, the section and the exact content, and it expires in 1 hour. The binding covers `subject`, `preheader`, `html`, `section` and `trackLinks`, and a create differing in any of the five is refused — omitting `trackLinks` means true, and a `preheader` the plan echoed has to be passed back with it. The subject and the preheader — the preview text shown after the subject in a recipient's inbox list — are the user's choice, and both stay editable on the draft afterwards through edit_email_activity. An email without a preheader shows whatever the mail client scrapes from the top of the body, usually the View-in-browser footer. `willCreate` names the account and the section by display name where the platform has one and by bare id where it does not, alongside the subject, the link-tracking decision, the links that would be wired and the data tags it validated. The section is fixed at creation, since edit_email_activity cannot move a draft and duplicate_email_activity takes no section, and there is no delete or archive tool, so a draft created in the wrong section stays there. `existingWithSameSubject` on the result lists any other activity in the target section whose subject matches, case- and whitespace-folded, each entry carrying `activityId`, `subject`, `createdAt`, `state` and `isEngineManaged`; it is absent when nothing matched. Matching subjects are common and legitimate — a monthly newsletter, an annual campaign, an A/B pair — so a match never blocks a create and adds nothing to the create call. What a match's `state` says about it: `draft` with `isEngineManaged:true` is an assistant-authored draft that edit_email_activity can still change; `draft` with `isEngineManaged:false` or missing is not assistant-managed and edit_email_activity refuses an HTML change on it, though a compatibility fallback can leave a draft the assistant created unstamped, so the flag means the draft is uneditable rather than that Apsis One authored it. A `sent` activity cannot send again, and duplicate_email_activity clones one preserving its `##…##` personalisation where rebuilding from HTML loses it. `scheduled` means a send is booked and has not gone out; cancel_scheduled_send is the only way to change or call it off, and duplicating leaves the original booked, so both would go out. `paused` is usually an activity held in Apsis One rather than a booked send that was paused, and cancel_scheduled_send releases it. `state` is a free-form string, so this list is not exhaustive: other values, among them `queued`, `processing` and `exporting` and any the platform adds later, mean sending is already under way. In every non-`sent`, non-`draft` case a new activity created alongside the match does not replace it — the match stays booked and both would go out. Returns `{ willCreate: { account, accountName?, section, sectionName?, subject, preheader?, trackLinks, links, dataTags, existingWithSameSubject? }, planToken, note }`, where `links` has no ids because no links exist yet.
plan_email_activity
Produces a link to the email as it will really render, for the user to look at. Returns `previewUrl` — Apsis's own page showing the real, fully rendered email — plus a `reviewToken`. The user sees the email by opening that link, and what they see there is the email a send would deliver. A link in this result is in front of the user only once it is in the reply. It follows creating or editing a draft in the same turn: the preview is how the user sees what was built, and asking "shall I preview it?" first is a question whose answer is always yes. It changes nothing, and the result is small — a link rather than the HTML — though the call itself still fetches the premailed render upstream every time, because the review token's hash is computed from it. The user cannot review an email they have not been shown, so asking first only costs them a round trip. A description, summary or re-rendering of the email as text, markdown or ASCII is not a preview of it: what a send needs reviewed is the actual email. The page needs no login, works on an unsent draft, shows merge tokens as placeholders (e.g. `<<First Name>>`) rather than anyone's real data, and one link stays valid across later edits. Pass `inline: true` only when you need the raw HTML yourself — your client can render HTML in the conversation, or you want to check your own work — because it costs several thousand tokens per call. Pass an activityId from create_email_activity and the `account` it was created in (`account` is optional — omit only if the user has a single account; with several, omitting is refused). The `reviewToken` is evidence that a preview was fetched for this content, and that is the limit of what it can certify: it cannot show whether the link was ever handed over or opened. Calling this tool costs nothing and is never the wrong move. If this environment has no hosted preview page, or no link can be made for this caller, the HTML is returned instead and there is no link, so this result gives the user nothing to open.
preview_email_activity
Finalize an image uploaded via create_email_image_upload: the gallery validates, decodes, and registers the object, then returns its public URL. Call it once the user says the upload page reported success — the upload happens in their browser, so there is no status code here for you to wait on. Pass the `upload_id` from create_email_image_upload and the same `account` (carry it across your calls; omit only if the user has a single account). Returns `{ imageUrl, width, height, account }` — put `imageUrl` into an `<img src="...">` in the HTML you pass to create_email_activity (the HTML is stored verbatim). **Every call is a write, not a check.** There is no read-only readiness endpoint for images, so this is also the only way to find out whether the object landed — but that means a repeat is a repeated registration attempt rather than a poll. A not-found cannot tell "not uploaded yet" apart from "expired or never landed": a missing object is a missing object. So at most one repeat is worth attempting in the same turn, and only after a not-found. A second not-found still cannot tell a file that never landed from a link that expired, which only the user can settle, from whether the upload page said "File uploaded", and a fresh link is what replaces an expired one (`expiresInSeconds`, when the presign reported one, indicates which is likelier). Any other failure — a timeout, an authorization error, a format or size refusal, or a server error — carries its own reason, and a new upload does not fix those. Do not loop.
finalize_email_image_upload
Finalize a whole set of images uploaded via create_email_image_uploads — use this instead of calling finalize_email_image_upload once per image, so one bad image cannot cost the user the good ones. Pass every `upload_id` from create_email_image_uploads, in the same order create returned them, and the same `account` (carry it across your calls; omit only if the user has a single account). `results` comes back in exactly the order you passed the ids, so it lines up with the slot labels the user was told to pick in — and every entry also names its own `upload_id`, so pair by that rather than by position whenever you are unsure. Each id is finalized independently: a failure is recorded against that slot and the rest still proceed. Returns `{ results:[{ upload_id, ok, imageUrl?, width?, height?, error? }], finalized, failed, account }`. Put each successful `imageUrl` into an `<img src="...">` in the HTML you pass to create_email_activity (the HTML is stored verbatim); what the user needs to know about a failed image is which one and why — a format refusal needs a different file, not a retry. **Each slot is a write, not a check** — there is no read-only readiness endpoint, so finding out whether an object landed and registering it are the same call, and a repeat re-attempts the registration rather than polling. Only a not-found is worth one repeat, and only for the slots that reported it. It is a tool error only when every slot failed. The upload is the user's to perform: the link goes to them, they pick the file, and their own browser sends it straight to storage, so the bytes never pass through this server, the model's context or its sandbox. Only the final message of a turn is reliably visible. Chat clients collapse a turn's intermediate steps behind a summary ("Worked for 48s"), so anything said in an earlier message of the same turn is likely never read — which is why a conversion that was performed, which file belongs in which slot, and any question still outstanding all belong beside the link rather than before it. Supported formats are PNG, JPEG and GIF. Each upload slot is signed for one type and cannot be changed once minted, so the format has to be settled before a link exists. Emails need those three anyway: WebP and AVIF do not render in Outlook. `content_type` follows the file name: .png is image/png, .jpg and .jpeg are image/jpeg, .gif is image/gif. The unsupported formats are .webp, .avif, .heic, .bmp, .tiff and .svg. A link signed for image/jpeg carrying WebP bytes is accepted by storage and then refused at finalize, which costs the user the whole trip. Converting such a file to PNG or JPEG is local work and needs no network. The disguised case matters as much: a file named .jpg can still hold WebP bytes, which is what saving from Google Images produces, and the upload page checks the bytes, so a format mismatch it reports is authoritative over the file name. The size limit is 5 MB, enforced by the signed upload policy rather than by this server, so an over-size file cannot be uploaded at all and storage refuses it with an error the user cannot act on. A phone photo is usually over it. Emails also load faster with smaller images, so around 1200px wide is a sensible target. For several local images, create_email_image_uploads presigns the whole set and returns one link, so the user visits a single page and picks every file there. Pick order is what pairs the files to the slots, and the link message spells that order out, so `images` goes in the order the files should be picked. One link per image is the fallback where that tool is unavailable. The bytes are not needed. A copy attached to the chat can be read for alt text, but an attachment is not the upload — only the link uploads. An attachment says nothing about the format. What is shown is a picture, not the file the user will pick: its real bytes and its name are both invisible, so seeing an image is not evidence that it is a PNG or a JPEG. Attachments in a chat are frequently WebP, which is the case that most often ends in a refused upload. What resolves it is the format each file actually is, or the format each slot was signed for, stated in the same message as the link, where the user can correct it before uploading. A web image does not need uploading: any `http(s)` image is accepted as it stands, so it can go into the email with its own URL and give the user a finished, previewable email immediately. Hosting it at Apsis is an improvement rather than a precondition, and the reason is that Apsis cannot control another site — the image may be blocked or changed after the send, and each open tells that site who the recipients are. Plain `http` is accepted here, but many mail clients block or warn on it, so an `http`-only image is worth hosting rather than merely offering to host. The link message already contains the ordered list with each slot's format, so restating it in other words can only contradict or lose it. (Observed: an assistant-written "choose these in this order: , , , ." that rendered four commas and no names.) What it cannot know is the part worth adding — which converted file goes with which slot. A slot carrying neither a `label` nor a `source_url` asks the user to find a file they cannot identify. Where the image was the model's choice — found on the web, or generated — the user has nothing to pick until they can get it, and `source_url` on that slot is what puts the download location into the link message. A generated image still cannot be uploaded by the model. It has to reach somewhere the user can get at — a path they can open, on a client running on their own machine, or a download, on a hosted one — and then an upload link for that. A sandbox path, a `file://` URL or a `data:` URI in the HTML renders broken for every recipient. Finalizing is one call to finalize_email_image_uploads with every `upload_id` for a set, or finalize_email_image_upload for a single image. Every `upload_id` is already held once the create has returned, so calling create again mints a second link, sends the user back to the browser to upload the same files twice, and orphans the first set. finalize_email_image_uploads finalizes every id even where one of them fails, and reports `finalized` and `failed` counts, so one bad image does not cost the user the good ones. A finalize is worth retrying only on not-found, once, in the same turn, because a missing object cannot be told apart from one that never landed. A format or size refusal fails identically on a second call, the bytes being unchanged. On not-found twice, what the upload page said — "File uploaded" or not — is what separates the two cases, and a fresh link is the way forward. An expired link has no refresh: create_email_image_upload again. Once finalize returns `imageUrl`, that URL goes into an `<img src="...">` in the HTML passed to create_email_activity, which stores it verbatim.
finalize_email_image_uploads
Change a custom attribute's display name. Reach for it when the user wants a field relabelled — a typo, a clearer word, or the term their team actually uses. Pass `account` and `section` (ids from list_accounts / describe_import_target), the `attributeVersionId` of the attribute to rename (from describe_import_target / describe_segment_target, or echoed by create_attribute) and the new `name`. It takes two calls. The first, without `confirm`, reads the attribute, runs the near-duplicate check and writes nothing: it returns `{ confirmed:false, willRename:{ attributeVersionId, previousName, name, section }, confirmToken, note }`, where `note` is written for the user and says what confirming would do and that nothing has been renamed yet. The second, with `confirm:true` and that `confirmToken`, renames. The token is bound to the account, the section and the exact inputs, `renameAnyway` included, and expires in 1 hour; a confirming call with a missing, expired or altered token is refused and renames nothing, and it repeats the reads and the near-duplicate check against the attribute as it is by then. A call from a client that does not know about `confirm` gets the first-call answer and renames nothing. Renaming again changes it back, and nothing else moves: the `attributeVersionId` stays the same, the stored profile values are untouched, and emails already built with the attribute's `##…##` data tag keep rendering, because data tags key on the attribute's internal id rather than its display name. On a confirming call you get `{ renamed:true, attributeVersionId, name, previousName, discriminator, duplicateCheck, note }`. The new name shows up in describe_import_target / describe_segment_target only once the section's cached attribute list expires (up to an hour); the rename itself is immediate, so the old name still listed there for that hour is discovery lag, not a failed rename. `{ renamed:false, reason:"similar-exists", candidates }` is a normal answer, not an error, and carries no token: another attribute already means the same thing (names differing only in case, spaces, dots, dashes or underscores count as the same field), so the rename would leave two fields looking identical — usually the existing attribute, named in `candidates` by its `attributeVersionId`, is the one actually wanted. `renameAnyway: true` makes the first call describe the rename anyway, once both are confirmed as wanted, and the flag is part of what the token is bound to. If `duplicateCheck` comes back `"skipped"`, that check could not run, which the `note` says; on a confirming call the rename happened regardless. Two things are refused outright: a hidden attribute (once an attribute is on its way out, the platform only lets its deprecation time change), and a standard platform attribute like First Name (renaming one would affect every import, segment and email in the section) — the `note` explains which.
rename_attribute
Change a subscription's name. Reach for it when the user wants one relabelled — a typo, a clearer word, or the term their team actually uses. Pass `account` and `section`, the `subscriptionId` (from list_subscriptions) and the new `name`. It takes two calls. The first, without `confirm`, reads the section's folders, runs the near-duplicate check and writes nothing: it returns `{ confirmed:false, willRename:{ subscriptionId, previousName, name, folder?, folderId }, confirmToken, note }`, where `note` is written for the user and says that nothing has been renamed yet. The second, with `confirm:true` and that `confirmToken`, renames. The token is bound to the account, the section and the exact inputs, `renameAnyway` included, and expires in 1 hour; a confirming call with a missing, expired or altered token is refused and renames nothing, and it repeats the checks against the section as it is by then. A call from a client that does not know about `confirm` gets the first-call answer and renames nothing. Renaming again restores the previous name, and nothing else moves: the `subscriptionId` stays the same, the consent already collected is untouched, and any campaign or import already pointing at it keeps working. The new name is visible immediately. On a confirming call you get `{ renamed:true, subscriptionId, name, previousName, folderId, folder?, note }` — `folderId` is the folder it sits in, the same value list_subscriptions reports so the two can be matched up, and `folder` is that folder's name (absent for a subscription that isn't in a folder at all, which list_subscriptions shows under "Top level"). A hidden subscription cannot be renamed, and the refusal says it isn't a subscription in this section rather than naming the real reason. The duplicate check below also skips hidden subscriptions, so a rename can quietly land on a name a hidden one already holds. `{ renamed:false, reason:"similar-exists", candidates }` is a normal answer that carries no token: another subscription in the same folder already has that name — or a near-duplicate of it (case, spacing, dots, dashes and underscores don't count as different) — so the rename would leave two that nobody could tell apart, and the existing one is usually what was meant. The same name in a different folder is allowed and is not refused. `renameAnyway: true` makes the first call describe the rename anyway, once the user has seen the existing one named and confirmed they want both, and the flag is part of what the token is bound to.
rename_subscription
Change a folder's name. Pass `account`, `section`, the `folderId` (from list_subscriptions) and the new `name`. It takes two calls. The first, without `confirm`, reads the section's folders, runs the near-duplicate check and writes nothing: it returns `{ confirmed:false, willRename:{ folderId, previousName, name }, confirmToken, note }`, where `note` is written for the user and says that nothing has been renamed yet. The second, with `confirm:true` and that `confirmToken`, renames. The token is bound to the account, the section and the exact inputs, `renameAnyway` included, and expires in 1 hour; a confirming call with a missing, expired or altered token is refused and renames nothing, and it repeats the checks against the section as it is by then. A call from a client that does not know about `confirm` gets the first-call answer and renames nothing. Renaming again restores the previous name; the subscriptions in the folder are untouched, keep their ids and their consent, and the new name shows immediately. The section's "Top level" is not a folder and cannot be renamed. On a confirming call you get `{ renamed:true, folderId, name, previousName, note }`. `{ renamed:false, reason:"similar-exists", candidates }` is a normal answer that carries no token: another folder in this section already has that name — or a near-duplicate of it (case, spacing, dots, dashes and underscores don't count as different) — so two folders that look identical would be indistinguishable when filing a subscription, and the existing one is usually what was meant. `renameAnyway: true` makes the first call describe the rename anyway, once the user has seen the existing one named and confirmed they want both, and the flag is part of what the token is bound to.
rename_subscription_folder
Save an audience as a persisted, reusable dynamic segment — a named filter that re-evaluates the matching profiles each time it is used and can be targeted by future sends. This is a deliberate, non-default action, not something to reach for automatically: the conditions you have already built are usable in this conversation as-is (most requests only need count_profiles), so saving only earns its keep when the marketer will want the same group again later or over time — e.g. reusing it across future campaigns, keeping it as an exclude / do-not-contact list, showing different content to different groups, or tracking how the group grows or shrinks. A one-off send does not justify a save (a send can run against the audience you just built), so "I want to send to them" alone is not a reason to persist. The word "segment" colloquially often means only "show me these people now", so it is not on its own a request to persist anything — which is why keep-versus-look-now is a distinct question, and why an explicit request to save a reusable segment settles it. A segment whose conditions are byte-identical to an existing one's is refused rather than created — the existing segment is named (in plain language, with its id) and nothing is saved; pass `createAnyway: true` to save it anyway. A segment that merely shares its name with an existing one, but has different conditions, is not refused: it is created, and the look-alike is named back to you in `similarByName` so you can flag it to the marketer. For a new campaign, prefer creating a new segment over reshaping an existing one to fit: a segment is a live filter, so editing it also changes who anything already using it reaches (fine when that's what they want — see edit_segment — but it isn't what "I need this audience for this campaign" usually means). `name` is what the marketer will recognise the segment by later, so a name for the people it captures (e.g. "VIP customers — Sweden") is the one that works; the identifier is generated and managed automatically, and the built conditions are validated before persisting. Returns the new segment's id and a plain-language `audienceDescription` of what was saved, already rendered in the marketer's own words, and re-describing the conditions instead throws that rendering away. `audienceDescription` is absent when it could not be rendered, which leaves plain words of the caller's own — with no operators, parentheses or comparison symbols. This tool's result is for relaying to a non-technical marketer: what reaches them, whether it is `audienceDescription` verbatim or wording of your own, contains no operators, parentheses, comparison symbols (>, <, =, ≠, !=), field types or precedence rules. **A saved segment cannot be deleted** — hiding it is the only way to remove it from view — so a segment created by mistake is permanent. And an error from this tool is not reliable evidence that nothing was created: saving is two steps, the segment then the filing that makes it visible, and a failure in the second leaves a real segment that no listing can see — list_segments reads the filing, which is the step that failed. **So an empty list_segments result is inconclusive here, not an all-clear**, and there is no reconciliation path: retrying can leave two real segments, one of them invisible. So after an error, whether to retry is the user's decision: a silent retry is exactly how the second, invisible segment appears. A saved segment can also be used to narrow a bulk send's audience (see send_email_activity's `segments` parameter), on top of the chosen subscriptions.
create_segment
The consent history for one person — every reliably-known change plus the guaranteed-correct current state, per subscription and channel. Evidential: use it to answer "can we prove this person opted in, and when?", in a form pasteable into a reply to a complaint or a regulator query. Pass `profileRef` (from lookup_profile: the profile's reference, carried verbatim), `account`, and `section`. The result's first text block is written for the user: a ready-to-paste consent timeline — every subscription and channel in date order, the current state of each marked '(current)', gaps in the record stated above the table. A summary loses those markers and gaps. The second block is the same data as JSON `{ completeness, timeline[], currentState[], history[], note?, timezone, window, section, account }` for follow-up questions; `timeline[]` merges `currentState[]` (the guaranteed-correct latest state, `guaranteed:true`) and `history[]` (reliably-known prior changes, ascending), and each row states whether it is the current state. Read `completeness.allHistoryKnown` and `completeness.pairsWithNoHistory[].why` before presenting history as complete, and pass `state:null` / `originKnown:false` through honestly rather than guessing a label. This tool is read-only. It never changes anyone's consent. No tool on this server changes a person's consent. The routes that exist are the recipient's own unsubscribe link and an opt-in through a subscribing import (create_import's `subscriptions`), and some customers use Apsis Vega without access to the Apsis One interface.
get_consent_timeline
Every event and activity recorded against one person in a section — email opens/clicks/bounces, imports, consent changes, and campaign sends among them — with a per-type summary over everything found plus the most recent slice. Reach for it when the user asks what happened to someone, or wants a specific interaction confirmed ("did she open the June newsletter?"). Pass `profileRef` (from lookup_profile: the profile's reference, normally its export key, carried verbatim), `account`, and `section`. Returns `{ available:true, completeness, coveredFrom, coveredTo, eventsFound, eventsReturned, partial, summary:[{typeId,typeName,count}], events:[{...,typeName,activityName?}], note, account, section }`, or `{ available:false, reason, note, resumeUrl?, eventsUnreadable?, account, section }`. `available:false` is a normal answer, never an error. `still-processing`: the export isn't ready yet. Its `resumeUrl`, when present, is a signed handle valid for 30 minutes; passed back as the `resumeUrl` argument on the next call, it keeps polling that same export rather than starting over. `too-large`: too much history to summarise right now — there is no link to fetch it another way. `no-events`: nothing was recorded (or nothing in the `since`/`until` window you asked for). `events-unreadable`: entries exist but no readable ones are left to show, either because none could be read or because the readable ones fall outside the `since`/`until` window; `eventsUnreadable` says how many could not be read, and this is not an absence of activity. `export-unavailable`: try again shortly. `busy`: the account has too many exports running; try again shortly (there is no `resumeUrl`, because no export was started). An account prepares at most 3 histories at once; further calls wait their turn, so a few at a time is faster than all at once. `completeness.allEventsRead:false` means some entries in the person's log could not be read and are absent from every count and from `events[]`; `completeness.eventsUnreadable` says how many. That figure covers the whole section regardless of `since`/`until`, because an unreadable entry usually has no usable time, so it may or may not fall in the window. `completeness` is the first field after `available`, and `eventsUnreadable` appears only when it is above zero. `coveredFrom`/`coveredTo`/`eventsFound`/`summary` always describe everything found (or everything within `since`/`until`, when given) — never just the returned slice. `partial:true` on the success shape means `limit` cut the returned rows short of `eventsFound`; that is unrelated to `too-large`, which means the export itself couldn't be read in full. Every event's `typeName`, and `activityName` when present, are already resolved to human-readable names — never a bare id. `typeName` always has a value; `activityName` only appears for events tied to a specific campaign. Either can degrade to showing the raw id when a name can't be resolved — the event is still present and still happened; only its label is unresolved. `limit` bounds how many of the most-recent events come back in `events[]` (default 20, max 200) — raise it only when the user actually wants to see more rows, not to try to read everything at once. `since`/`until` (ISO-8601, with an explicit offset or `Z` — no bare local time) narrow the whole answer to a window, for when the user is asking about a specific period rather than the full history.
get_profile_history
Read-only send report (statistics) for one email activity. Pass `activityId` (from create_email_activity / send_email_activity / list_email_activities) and the `account` it lives in (`account` is optional — omit only if the user has a single account; with several, omitting is refused). Returns { activityId, account, state, subject, report, note? }. A report only exists once sending has started: the reportable states are `sent`, `active` and `paused`. For anything else — a draft, a `scheduled`/`queued`/`processing`/`exporting` activity, or any state this list does not name — `report` is null and a `note` comes back. Read the `state`, not just the note: `state` is free-form upstream, and the note is worded for the pre-send case ("send it first"), so for an activity that is past sending without being reportable it will read as advice that does not apply. In that case `state` is what says where the activity actually is. Either way there are no statistics yet, so a zero would be an invented figure, not a result. For a reportable activity, `report` is the statistics object: absolute counts `sent, uniqueSent, delivered, normalizedDelivered, open, uniqueOpen, click, uniqueClick, bounce, uniqueBounce, unsubscribe, uniqueUnsubscribe, abuse (= spam complaints)` plus their `*Rate` fields. Every `*Rate` (deliveryRate, openRate, clickRate, clickToOpenRate, bounceRate, unsubscribeRate, abuseRate) is a fraction in 0..1 — multiply by 100 to show a percentage (e.g. openRate 0.125 → 12.5%). The rates are computed upstream from unique counts: openRate, clickRate and unsubscribeRate over `normalizedDelivered`, clickToOpenRate as uniqueClick over uniqueOpen, bounceRate as uniqueBounce over sent, and deliveryRate as delivered over sent. `normalizedDelivered` is usually equal to `delivered` but can be lower, so a rate recomputed over `delivered` can come out a few percent low. `open` and `click` count every open and click event, so they can exceed the number of people in `uniqueOpen` and `uniqueClick`. `click` and `uniqueClick` cover all of the email's links together; a per-link click figure counts unique people per link. Someone who clicked two links counts once on each, so the per-link figures overlap: their sum can exceed `uniqueClick`, and it is not `click` either. A genuinely-sent activity with an empty audience returns real zeros — that is a valid answer, not an error. Mutates nothing.
get_email_activity_report
Returns all of one section's non-deleted email activities, newest first, as { account, section, activitiesInSection, activitiesReturned, activities, truncationRisk?, note? }. Pass `account` and `section` (ids from list_accounts / list_sections) to say which section to read; `account` is optional (omit only if the user has a single account) and so is `section` (omit only when the account has a single one — with several, omitting is refused, naming list_sections). This is also the list `plan_email_activity` compares against before a draft is created, so you can reuse one instead of rebuilding it. `activitiesInSection` counts every live activity before any `state` filter; `activitiesReturned` is `activities.length` — fewer than `activitiesInSection` with no `state` filter applied means the client cut the list before you saw all of it. On a very busy section the response can also carry `truncationRisk:true` plus a leading `note` warning it may already be cut off — narrow with `state` and re-list rather than trusting it as complete. Each returned activity only carries `id`/`name`/`subject`/`state`/`createdAt`/`isEngineManaged`/`sendAt` — other fields you may have seen on a single activity (from other tools) are not repeated here. Each activity carries a `state` — `draft` means editable and sendable; `sent` means it has already been sent (Apsis One cannot re-send an activity); `scheduled` / `queued` / `processing` / `exporting` / `paused` mean a send is already under way or committed. A `sent` email's content can go out again as a copy: `duplicate_email_activity` makes a fresh draft that keeps the `##…##` personalization (rebuilding it from a preview's HTML does not). A `scheduled` or `paused` one is changed or called off with cancel_scheduled_send instead: a copy leaves the original booked, so both would go out. `state` says whether a send can be proposed at all; send_email_activity refuses `sent`, `scheduled`, `queued`, `processing`, `exporting` and `paused`, and finding that out here is cheaper than after building the send. Each activity also carries `isEngineManaged` — `true` only for emails the assistant itself created. You can edit the HTML of an `isEngineManaged:true` draft with edit_email_activity; for any other activity (created in Apsis One) the content is view-only to the assistant (edit_email_activity refuses an html change) though you can still preview, test-send, duplicate and send it. Treat missing/false as not engine-managed. A draft cannot be deleted here; an unwanted one can be renamed through its subject and left unsent, since edit_email_activity changes the subject of any draft. Pass `state` to narrow the list. `state:"scheduled"` is how you find booked sends, so you can name one back to the user and call it off with `cancel_scheduled_send`. This tool is now section-scoped, so if you don't already have a section in hand for a "cancel the campaign" style request, sweep: call list_sections, then call this tool with `state:"scheduled"` for each section in turn, until you find the booked send. Filtered `"scheduled"` results are ordered by send time, soonest first. An activity with a booked send also carries `sendAt:{ instant, local, timezone }`. The local time and the zone are the pair a user can check; the raw instant is not, so someone picturing a different timezone has no way to catch a mistake in it. If a filter matches nothing you get an empty list plus a `note` naming the states that are present; re-aim on that rather than telling the user the section is empty.
list_email_activities
List the account's profile imports — use it to find an import whose id is no longer at hand (a previous conversation, or context no longer held), and to explain a refused create_import. Pass `account` to choose the account; omit `section` to sweep the account's sections (up to 20 — any beyond that come back named in `sectionsSkipped`), or pass one to narrow to it. Passing a `section` explicitly also skips the sections lookup, so it still works when that lookup is failing. Sweeping is the default because the platform's concurrent-import limit is per account and counted across all sections — the import blocking a new one may sit in a section nobody mentioned. `activeCount` is how many of the imports returned count toward that limit; it is a lower bound whenever `sectionsUnavailable` or `sectionsSkipped` is non-empty, since an import in an unread or unswept section is not counted. Each row carries `{ importId, section, fileName, status, createdAt, ageMinutes, total, successful, unsuccessful, updated, skipped, awaitingUpload, countsTowardActiveLimit, cancellable }`. `awaitingUpload:true` means the file has not landed yet — usually the link was never opened or the upload failed silently, though it is also the state while an upload is genuinely in flight, and nothing in the row tells those apart: an upload can begin at any point before the link lapses, so a large `ageMinutes` is not evidence the link was abandoned; an abandoned one frees its slot by itself when the link lapses after about an hour, and uploading or cancelling frees it sooner. `countsTowardActiveLimit:false` means this import is not why a create was refused (a `queued` import never counts, and neither does one started in the Apsis One interface — only imports made through the API do). `cancellable:false` means the import has reached a terminal state — finished, failed, already cancelled or expired — so there is nothing left to stop; calling cancel_import on one is not an error, since an already-cancelled import answers `alreadyCancelled:true` and one that finished, failed or expired answers `not_cancelled` with a reason. By default this returns every unfinished import plus a few recent finished ones per section; pass `includeCompleted:true` for the fuller history. `sectionsUnavailable` lists sections whose imports could not be read, distinct from a section genuinely having none; `sectionsSkipped`, when present, means the account has more sections than one sweep covers. `ageMinutes` counts the minutes since the import was created, not since it entered its current status, so an import that spent an hour uploading and has just started processing reads about 60.
list_imports
List existing segments in your account, so you can reuse one instead of rebuilding it. The listing is **account-wide and `section` does not narrow it** — pass one and it is echoed back but not applied, so a segment listed here may belong to a different section. Omit `section` and the account's sections come back for you to pick from instead of any segments, unless there is exactly one, which is selected silently. An account with **no** sections returns that same empty choice, which is a dead end rather than a prompt: there is no section id to use and nothing for the user to choose from. By default only visible segments are returned. Set `include_hidden: true` to also return hidden segments — each result then carries a `hidden` flag — which is how you find a hidden segment the marketer wants back before unhiding it with set_segment_visibility. Each result carries only a segment's `segmentId`, `name`, and (when requested) `hidden` — not its conditions. The listing gives you a segment's name and id, and **describing who a segment saved earlier actually targets is not something this tool does**; describe_segment returns what one targets (count_profiles will size a `segmentId`, but returns no description for it). A name alone does not say who a segment targets. `hidden` is a field name, not a word for the marketer; in plain language the segment is simply "hidden". A segment listed here can also be used to narrow a bulk send's audience (see send_email_activity's `segments` parameter) — describe_email_send's own `segments` list is the section-scoped view of the same thing.
list_segments
Show the account's subscriptions and the folders they are filed in — the list to reach for when the user asks what they collect consent against, or before creating, renaming or hiding anything. Pass `account` (from list_accounts) and `section` (from list_sections); omit `account` only if the user has a single account. Returns `{ folders:[{ folderId, name, isRoot, subscriptions:[{ subscriptionId, name, emailConsentAttributeId? }] }], limits, note }`. A subscription's folder is decided when it is created and can never be changed — there is no moving one between folders. Names are not unique: the same name can appear in two folders, or even twice in one, so a name alone does not identify a subscription — the folder it is in is part of its identity, and two subscriptions with the same name in different folders are different things with separate consent. Nothing in this list is cut off at a limit. `limits` is `{ subscriptions, folders }`, each `{ count, createLimit, canCreate }`: `createLimit` bounds what can be created here, not what a section can hold or what this list returns — sections built in the Apsis One interface can be far past it, and `count` is then simply larger than `createLimit`. `canCreate` is per kind, because a section at the folder limit can still take another subscription, and vice versa; the one about to be used is the one that matters, and a closed create is worth surfacing before the user chooses a name. Both counts cover visible items only, and so does the create check — deliberately, because hiding is the only removal available here and there is no un-hide, so counting hidden ones would strand a section at ten lifetime creates. The practical consequence for a section exactly at a limit: hiding something frees room for a new one; past it, the `note` says how many would have to go. A subscription shown under "Top level" is not in a folder (it was made in the Apsis One interface). It can be renamed and hidden from here, but new subscriptions cannot be created there. To find out how many people are actually opted in to one of these, use count_subscribed — this list says what exists, not who is subscribed. Subscriptions cannot be used as segment conditions.
list_subscriptions
Show the Apsis One accounts this user can act in (ids and names). To act in one, pass its id as the `account` argument to the other tools; the id carries across calls, since no selected account is kept between calls. With a single account, `account` can be omitted and is used automatically. This result is written for the user rather than for the model: where it reports accounts that are not enabled, or that could not be checked, it names the action that resolves the situation, usually who to contact, and that is the sentence a summary of it loses — for a user whose accounts are not enabled it is the only actionable content there is.
list_accounts
List the valid Apsis data tags (`##<discriminator>##`) you can use for personalization in an email's body or link URLs, for the given account + section. Call this before authoring `##…##` tags — create_email_activity rejects unknown discriminators. Returns { account, section, dataTags }, where each tag is { discriminator, name, kind } and kind is "standard" (built-in profile attributes, e.g. com.apsis1.attributes.firstname — the short form ##firstname## also works), "custom" (usercreated.attributes.*), or "system" (send-time tags). Pass `account` and `section` (ids from list_accounts / list_sections) and carry them across calls; `account` is optional (omit only if the user has a single account) and likewise `section` (omit only when the account has a single one — with several, omitting is refused). Mutates nothing.
list_email_data_tags
Show the sections in an account (id and name). Pass `account` to choose which account's sections to list (required when the user can act in several — it's refused otherwise, pointing to list_accounts; omit only if the user has a single account). Pass the chosen section id as the `section` argument to the tools that need one — most of the surface takes it, including the email, import, segmentation and subscription tools and the profile tools — and carry it across your calls. If the account has a single section you can omit `section` and it's used automatically.
list_sections
Discovery for sending a bulk email: returns { account, section, from, subscriptions, segments } for the given account and section. `account` and `section` are ids from list_accounts and list_sections and carry across calls; `account` is optional (omit only if the user has a single account) and so is `section` (omit only where the account has a single one — with several, omitting is refused). `from` resolves the sender's domain and only the domain: {mode:"fixed",domain} means that one validated sending domain is the only one accepted; {mode:"choose",domains} means one of the listed validated domains, with no default among them: which one is the marketer's choice; {mode:"unavailable",note} means the account requires a validated domain and has none configured, so a send cannot succeed. `trialDomain`, when present on fixed or choose, names which listed domain is the Apsis trial domain, so recipients see an Apsis domain rather than the brand's. When it is the only domain listed, there is nothing for the marketer to choose between. Without `trialDomain`, none of the listed domains is an Apsis domain. The sender display name and the reply-to address are the two things the send tools require that this tool does not resolve. Neither is derivable from the domain mode and neither has a sensible default: the display name is what every recipient sees in their inbox, and a reply-to nobody watches silently loses every reply. Both are the marketer's to supply, and the account name is a reasonable suggestion for the display name rather than an assumption. `subscriptions` lists the section's subscriptions ({ subscriptionId, name, folder? }) across all folders; names are not unique, not even within one folder, so `folder` is what separates two that read alike. Where the section has exactly one subscription, send_email_activity uses it automatically and `subscriptionIds` is unnecessary; where it has several, the chosen subscriptionId(s) go to send_email_activity, which refuses a send that omits them. `segments` ({ segmentId, name }[]) are this section's segments a send can be narrowed to (see send_email_activity's `segments` parameter) — unlike `list_segments`, which is account-wide, this is already filtered to segments that reference this section. Hidden segments (see set_segment_visibility) are omitted here but can still be named on a send if the user knows the id. `segmentsNote`, when present, means the segment listing failed — don't read an absent `segments` entry as "this section has none" in that case. Optionally pass `activityId` when you already have a specific activity in mind: if that activity was not created by the assistant (created in Apsis One), the result adds `reAim:{isEngineManaged:false, replacing:{from,subscriptionIds,segments?}}` — sending it overwrites the sender/audience its author chose, and `replacing` is exactly what it overwrites. Mutates nothing.
describe_email_send
Returns what a saved segment targets: its `name`, its current `conditions` as an AQLContainer, and an `audienceDescription` in plain words, written with the field names as they are now. A segment is described only when its stored conditions read back exactly: conditions are parsed from the stored query, and the segment is described only if rebuilding them gives that same query. Otherwise the result is `describable: false` with a `reason` (`not-found`, `unsupported-construct`, `unknown-reference` or `subscription-condition`) and no conditions, because a segment's name does not say who it targets. The stored query itself is never returned, no profile count is included (count_profiles sizes a segment), and nothing is changed. Conditions can hold values that identify a person, such as an email address, and they are returned as they are stored. This tool's result is for relaying to a non-technical marketer: what reaches them, whether it is `audienceDescription` verbatim or wording of your own, contains no operators, parentheses, comparison symbols (>, <, =, ≠, !=), field types or precedence rules.
describe_segment
Explain why an import's rows landed where they did: per-reason counts across the whole category, plus a few example rows for each reason. Reach for it once get_import_status shows a non-zero `unsuccessful` or `skipped` and which rows and why matters — a count cannot tell a malformed address apart from a locked profile. Pass the `section` and `account` the import belongs to (both echoed by create_import, and on every list_imports row) plus its `importId`. `category` defaults to `unsuccessful`; pass `skipped`, `updated` or `successful` for the others — `skipped` is the one for an import that reports people it left alone, and `updated` confirms an update actually happened. The result is either a report — `{ available:true, category, rowsInCategory, rowsRead, partial, reasons:[{reason,count,examples}], retentionDaysRemaining, note }` — or `{ available:false, reason, note }`. The `note` states the totals in plain language either way, and is the only place that says when a breakdown is incomplete. `available:false` is a normal answer, never an error. `still-processing` means the import has not finished — get_import_status reaches a terminal status first. `no-rows` means that category is genuinely empty for this import. `retention-expired` means the row-level personal data was deleted 30 days after the import ran, which is not the same as anything having gone wrong — the counts on get_import_status still stand. `report-unavailable` means the import finished but its record has been archived, so no report can be built for it, and get_import_status will keep reporting the import as finished rather than surfacing a report. The `examples` are real rows from the user's own file, holding only the key column and the mapped columns the import wrote; they are empty when those columns couldn't be matched to the report, and the `note` then says so. One or two make the problem concrete without listing them all. `partial:true` means the export was too large to read in full, so the reasons are the common causes rather than a complete tally. The detail behind `retentionDaysRemaining` disappears once it reaches zero. This reads the entire export server-side and can take a few seconds on a large import; it is not something to poll, and it returns nothing new until the import has finished.
get_import_report
Discover where profiles will be imported and how to map columns. Pass `account` (from list_accounts) to choose where to import — carry it across your calls; omit only if the user has a single account. Omit `section` to list the account's sections (a sole section is auto-selected silently; with several, pick one and call again). With a `section`, returns its attributes — each with the `attributeVersionId` create_import needs — plus the fixed email keyspace. The result echoes the resolved `account` (and, when resolved, `section`) — reuse those ids on create_import. Profiles are matched on email (the single key column). The result also carries a `subscriptionHint`, naming the subscriptions that can be offered for the imported profiles: `status:"available"` with `recommendation:"propose-single"` names the one subscription in `subscriptionHint.subscriptions`; `recommendation:"ask-which"` means several exist there; `status:"none"` means there is nothing to offer; `status:"unavailable"` means the subscriptions could not be loaded, which is a load failure rather than evidence that none exist. Subscribing creates GDPR opt-in consent. Pass the chosen ones to create_import's `subscriptions`, as `{subscriptionId}` — there are two ways to subscribe: everyone in the file (omit `column`), or only some people, from a column in the file holding 1 for those to subscribe and 0 for the rest (`{subscriptionId, column}`); a column holding anything other than 1/0 subscribes nobody, detailed further in create_import's `subscriptions` description. create_import also requires `updateExistingProfiles`: whether the people already in the account should be updated from the file, or left exactly as they are. There is no default.
describe_import_target
Lists the people behind one sent email's engagement — who opened it, who clicked, and equally who did not. `engagement` is one of `opened`, `clicked` (any link), `clicked_link` (pass `link`: text matched, ignoring capitalisation, against each clicked link's title or URL), `not_opened`, `not_clicked`, `opened_but_not_clicked`. Reach for it once the user has a rate from get_email_activity_report and wants the individuals. The section is read from the email itself. Every "did not" case counts only people the email was actually delivered to, never everyone it was not sent to. `recordCount` is required: how many people to return. Above 200 the answer risks being cut short by the client, so a count beyond what will actually be read costs the answer rather than adding to it. Returns `{ outcome, ..., account }`. `outcome:"ready"`: `followUp` comes first, then `note` and the counts, with `people[]` and `segmentCondition` last. `people[]` (email, name where known, `profileRef`, plus `opens`/`clicks` for `opened`/`clicked`), `returned`, `moreAvailable` (more people match than were returned), `incomplete` (the list was cut short by its size — treat exactly like `moreAvailable`), `truncationRisk`, `matchingTotal` and `recipients` (absent means they could not be counted, never zero), `recordGaps` (the email's own report counts more people than are still on record, because older activity expires — each gap's `effect` is `people-missing`, or for a "did not" case `may-include-people-who-did`: someone whose open or click aged out now appears in the list),`suggestedSegmentName` and, last, `segmentCondition`. `note` is written for the user and states the population, what bounded the list, and that the list shows who, not when. `followUp` is a yes-or-no question already written for the user, offering to keep these people as a reusable segment; it is put to the user as written once the list has been shown, and only a clear yes to it is create_segment with `segmentCondition` unchanged and `suggestedSegmentName`. For when someone opened or clicked, a person's `profileRef` (the profile's export-key reference, carried verbatim) works with get_profile_history. Other outcomes are normal answers, not errors: `no-one`, `not-sent` and `cannot-list` (`note`, written for the user, says why); `still-processing` (pass `resumeToken` back on your next call, with the same other arguments, to continue this list instead of starting over); `expired` and `resume-invalid` (the token can't continue this request — e.g. an argument changed — so call again without `resumeToken`; resending it fails the same way every time);`rejected` (`detail` says why); `unavailable` (try again shortly). `attributes` is an optional list of up to 10 attribute ids, as `describe_segment_target` lists them for the email's section. Each person then carries `attributes` (`[{ id, name, value }]`) for the requested attributes they have a value for, and `requestedAttributes` lists what was asked for, so a requested attribute missing from a person's `attributes` has no value for that person. The people returned, and any attribute values with them, are the customer's personal data. Mutates nothing.
list_email_activity_engagement
Send a test copy of an email activity to up to 10 explicit recipient addresses (not the real audience) so the user can preview the delivered email in their own inbox. Does not schedule or send the real campaign. A `from` address is required, and setting it is a **permanent write to the activity**, not a per-test override: the platform cannot send without a From address, so the sender is written onto the activity and stays there. Because of that write, a test send is only made on a draft: an activity whose send is booked ("scheduled" or "paused"), already sent, or whose state can't be confirmed is refused before anything is written, and `cancel_scheduled_send` returns a booked one to a draft. When the draft already had a different sender, the result starts with `reAim: { replacing: { from }, setting: { from } }` naming the sender that was replaced; without `reAim` nothing was replaced. Its domain must be a validated sending domain for the account, or a clear error is returned; a rejection usually means the account's sending domains have changed, and a fresh describe_email_send lists the current ones to choose from. `from.apsisDomain: true` on the result means the test went out from an Apsis domain (the trial domain) rather than the brand's own. Pass the `account` the activity lives in (`account` is optional — omit only if the user has a single account; with several, omitting is refused). The test goes to the user's own address(es) so they actually receive and review it — the returned `reviewToken` is evidence that a test send was made for this content, which is as far as it goes: it cannot show whether anyone opened the mail, so don't use it to send blind. Returns { reAim?, status: "accepted", reviewToken?, note?, account } once the platform accepts the test. `reviewToken` is best-effort: when it cannot be minted the test has still been sent, and a `note` says to call preview_email_activity for one instead — so its absence never means the test failed.
send_test_email_activity
Sends a real bulk email campaign to the audience defined by the chosen subscriptions, optionally narrowed by a segment. This is not a test and cannot be undone. `account` is optional — omit only if the user has a single account; with several, omitting is refused. `subscriptionIds` is optional too: omitted, the section's sole subscription is used; where the section has several, omitting is refused and the chosen ids come from describe_email_send; where it has none, the send is refused, there being nothing to send to. When `subscriptionIds` is omitted, `section` is needed as well if the account has several sections. A segment narrows the subscription audience rather than replacing it: subscriptions are still chosen, and only people in both get the email. Ids come from describe_email_send's `segments`. `segments.include` requires all the listed segments to match by default and `segments.exclude` drops a profile matching any of them, with `includeOperator` and `excludeOperator` overriding each. Omitting `segments` sends to the whole subscription. A real send requires three things together: `confirm:true`, a `reviewToken` from preview_email_activity or send_test_email_activity, and an `audienceToken` from this tool's own no-confirm step. The `reviewToken` records that a preview was fetched for this content and nothing more: whether the rendered email was ever put in front of a person is not visible from here. A sent campaign reaches a live audience and it cannot be recalled. A missing or expired token, or one minted for different content or a different audience, is refused with the remedy named. For an email the assistant wrote, a real send also needs `previewReviewed:true`, which records that the user said they reviewed this version of the email, through the `previewUrl` page or the rendered HTML where there is no hosted page, and the `reviewToken` has to be preview_email_activity's: a test send's does not count. The no-confirm step carries `previewUrl` for such an email, wherever the environment has a hosted preview page and the link could be made; without one, preview_email_activity returns the HTML in its place and `previewUrl` is absent throughout. Once the `reviewToken` and `audienceToken` verify, a send missing either is returned as { status:"confirmation_required", reason:"preview_not_fetched" or "preview_not_reviewed", campaign:{ name, subject }, previewUrl?, note, sendAt? } and sends nothing; a missing or invalid token keeps its own refusal above, ahead of this one. That hold comes before anything is written, so the shape carries no `restore`. The server checks the boolean, never the conversation, and an edit to the email makes an earlier review stale. An email built in Apsis One is not gated this way, and `previewReviewed` on it is ignored. Called without `confirm`, this validates the from-address, subscriptions and segments being proposed, and returns { restore, confirmed:false, canSend, willSend, images, audience, audienceToken, warnings, previewUrl?, note, reAim?, account }. `canSend:false` means the audience or content is not sendable and nothing is sent. That preview writes the proposed values to the activity in order to validate them, and restores what the activity carried before, before the response returns. Where the activity's current sender has no email at all, or carries a scheduled time under a send type not recognised here, the call refuses before writing anything. Otherwise `restore.restored` reports what happened, rather than the activity being assumed untouched because this was only a preview: `true` means the restore completed, which includes outcomes that are successes without being byte-for-byte identical to before — a field with no prior value stays as the proposed one, and a booked send whose time had already lapsed is reset to unscheduled, a past time being unrestorable. Three structured flags carry the rest, and are the branchable form of what `note` also says in prose: `restore.senderPartial:true` (the prior sender was itself only partly specified, so restoring it filled the gaps), `restore.segmentationPartial:true` (the same for segmentation) and `restore.scheduleStaleResidue:true` (a stale scheduled-time leftover this call's own write cleared, rather than a restore). `restored:false` means the restore PATCH itself failed after the proposed values had already been written, and the shape is then { restored:false, previous, leftInPlace, note }, where `leftInPlace` is what the activity now carries and `note` says why. `restore` is present on every non-sending response that reached that write — this preview, `canSend:false`, the unresolved-links refusal, and the missing-unsubscribe and expiring-images refusals below — but not on `send_time_not_confirmed`, which happens before anything is written. Calling again with `confirm:true`, the `reviewToken`, and the `audienceToken` from that same preview commits the send, acknowledges ordinary warnings, and returns { status:"scheduled", immediate, sendAt?, account }. `immediate` rather than `status` distinguishes the two outcomes: the platform's word for a committed send is "scheduled" either way, so `immediate:true` means the campaign has gone out and `immediate:false` means it is booked for `sendAt`. `willSend.from` holds { name, email, replyToEmail, replyToName } exactly as supplied. Neither the display name nor the reply-to is derivable from anything else, describe_email_send resolving only the sending domain, so whatever is in those fields is a value the caller supplied. The From line a recipient sees is set only here: a subject can be corrected on the draft afterwards and that cannot. Where describe_email_send offered several domains, the one in `willSend.from` is the marketer's pick. `willSend.from.apsisDomain:true` means the email comes from an Apsis domain rather than the brand's own, which recipients will see; the confirmed result carries `from.apsisDomain` the same way. A send refused because the platform rejected the sender's domain usually means the account's sending domains have changed since describe_email_send ran, and a fresh describe_email_send lists the current ones for the marketer to choose from again. Every recipient sees the From line. `audience` holds { subscriptionIds, segments?, profileCount, note }. `profileCount` is `null` where the estimate was unavailable, which never blocks the send. It is also the only count of how many people the send reaches. Changing `subscriptionIds` or `segments` after previewing invalidates the `audienceToken` as `audience-changed`, and a fresh preview mints a new one. An email whose links resolve to nothing, typically a copy whose links were not copied with it, is refused on the no-confirm step and the confirming one alike: { restore, status:"refused", reason:"unresolved_links", unresolvedLinks, sample, warnings, note }, and nothing is sent. No flag overrides it, and `note` is the remedy, written for the user. An email that is already sent, booked or going out is refused the same way, before anything is written: { status:"refused", reason:"already_sent"|"already_scheduled"|"send_in_progress", state, sentAt?, sendAt?, note }. It is a result, not an error, so asking again returns the same refusal. `sentAt` is when an already-sent email went out and `sendAt` is when an already-booked one is due, each present only when the email carries that time. A missing unsubscribe link is not waived by `confirm:true`: the send returns { restore, status:"confirmation_required", reason:"missing_unsubscribe_link", warnings, note } and sends nothing. It proceeds with `acknowledgeMissingUnsubscribe:true` alongside `confirm:true` and the same `reviewToken` and `audienceToken` — a confirming call without the audience token is refused regardless of the acknowledgement. The server checks the boolean, never the conversation. Images on links that expire, typical of image-generation services, break for anyone opening the campaign later, and are likewise not waived by `confirm:true`: the send returns { restore, status:"confirmation_required", reason:"expiring_image_urls", images, note } and sends nothing. Hosting them is the fix and takes one call — those URLs to host_email_images_from_url, which Apsis fetches itself with nothing needed from the user, then edit_email_activity with the hosted URLs in the `src`s. Otherwise `acknowledgeExpiringImages` has to carry exactly the URLs this refusal reported in `images`, as an array of strings rather than a boolean; an empty array, a subset, or URLs from an earlier refusal do not satisfy it, and it is re-checked against a fresh read on every call, so acknowledging before an edit_email_activity does not carry over. `images:{ remoteHosts, expiring, unreachable }` comes back from the no-confirm step. `remoteHosts` lists hosts Apsis does not control; those are not gated and leave `canSend` untouched, a customer's own CDN being a perfectly good host. The host of a stylesheet link or of a `url()` in a style is listed there too, marked in `styleHosts`, and cannot be hosted. Where `remoteHosts` is non-empty the `note` leads with the offer to host them, written for the user. Each open of the campaign tells those hosts who the recipient is. Hosting is host_email_images_from_url for the set and then one edit_email_activity to swap the `src`s, which invalidates the reviewToken, so a fresh preview follows. `reAim:{ replacing, setting }` appears on the no-confirm step where the activity was created in Apsis One rather than by the assistant: sending replaces the sender and the audience its author chose. `sendAt` books the send for a future time instead of sending it now. ISO-8601, either a local time ("2026-09-10T09:00", resolved in the signed-in user's own Apsis timezone) or an exact instant with an offset or Z ("2026-09-10T09:00+02:00", honoured verbatim). A bare timestamp number is refused, as is anything sooner than 5 minutes or further out than a year, with the limit named. Relative wording ("next Tuesday morning") has to be a concrete date by the time it is passed, and which date it names is the marketer's to say: "next Tuesday" said on a Monday can mean the following day or the week after. The resolved local time and zone come back from the no-confirm step in `willSend.sendAt`, and they are the only form of the booking a marketer can check. A wrong hour surfaces only once the campaign has gone out. Both the preview and the result carry `sendAt:{ instant, local, timezone }`, and a confirming call for a scheduled send is accepted only if it passes `sendAtLocal` and `sendAtTimezone` back exactly as the no-confirm step reported them in `willSend.sendAt`, copied rather than recomputed or reformatted. A mismatch returns { status:"confirmation_required", reason:"send_time_not_confirmed", note, sendAt } and sends nothing, the note giving the real local time and zone so the retry is one call; nothing was written to the activity, so that shape carries no `restore`. Supplying either field without a `sendAt` is refused too, there being no booked time to confirm on an immediate send — and that is the only `send_time_not_confirmed` refusal with no `sendAt` on it, so the presence of `sendAt` separates "these fields were sent for a send that has no scheduled time" from "the pair sent is not the pair this send resolved to". The `note` says which in prose either way. Where the chosen time lapses between the preview and the confirm, the send is refused on the time itself and nothing goes out. Two things change between booking a send and its going out: the audience is resolved at send time, so a subscriber count from describe_email_send is a snapshot that will have moved, and the content is not frozen, so an edit made in Apsis One before the send fires changes what ships. cancel_scheduled_send moves or calls off a booked send, returning it to a draft that can be edited and then re-sent or re-booked, which is also how rescheduling works — there is no reschedule tool. duplicate_email_activity does not change a scheduled campaign's time: the original stays booked, so the campaign goes out twice.
send_email_activity
Hide or unhide a segment to declutter the marketer's segment list. `hidden: true` hides it; `hidden: false` unhides it. Hiding is safe and fully reversible — it is not a delete: a hidden segment keeps working in every existing email, automation, and activity that already uses it, and it can be unhidden at any time. Unhiding clears the hidden marking outright rather than restoring a previous value, so if the segment was hidden from somewhere else by another route, unhiding here makes it visible everywhere. Hiding only removes it from the everyday segment list (and the Audience product's list); it never changes who the segment targets or breaks anything currently using it. There is no hard delete — hiding is the only way to remove a segment from view, by design. To unhide, first find the hidden segment with list_segments using include_hidden: true, then call this with hidden: false. Hiding takes effect on the first call: there is no confirm step, so naming the segment back to the marketer is the only check that the right one is hidden. Hiding is reversible and changes nothing that uses the segment, so that is the only consequence there is to state. Returns the segment's id and its new hidden state.
set_segment_visibility
Size an audience: provide exactly one of `aql` (an ad-hoc AQLContainer — build one from describe_segment_target's attributes/events and their operators, never write AQL text) or `segmentId` (from list_segments/create_segment). Returns the estimated profile count, plus — on the `aql` arm only — an `audience_description` already rendered in the marketer's own plain language; it is rendered from the query itself, so it is what the count measures, where a re-description is a reading of the conditions that can diverge from the query. The `segmentId` arm returns no description (there is no container to render it from), and `audience_description` is also absent on the `aql` arm whenever it could not be rendered. When the field is absent, that leaves plain words of the caller's own — with no operators, parentheses or comparison symbols. This tool's result is for relaying to a non-technical marketer: what reaches them, whether it is `audience_description` verbatim or wording of your own, contains no operators, parentheses, comparison symbols (>, <, =, ≠, !=), field types or precedence rules. Out-of-scope or malformed `aql` is refused with a clear reason before any backend call (schema validation only). An unknown `segmentId` is refused with a clear reason too, though that path does look it up against Audience Meta first — it just never reaches the Audience Export count call.
count_profiles
Create a profile import (add new / update existing, matched on email) and return an upload link for the user to open. Pass `account` and `section` (ids from list_accounts / describe_import_target) to say where to import — carry them across your calls; omit `account` only if the user has a single account. Rules: exactly one email keyspace (fixed) with one key column; map each data column to an attribute by its `attributeVersionId` from describe_import_target; new profiles are always created, and email-matched ones are updated or left untouched according to `updateExistingProfiles`. This call commissions a write over profiles that already exist, not just an addition: with `updateExistingProfiles: true` an update writes every mapped column onto a live profile, and with `options.clearExistingAttributes: true` an empty cell in the file erases the value already stored. Neither is undone by cancel_import, which stops the remaining rows and leaves everything already written in place. It can also refuse with a 403 when the mapped attributes are not write-enabled through the email keyspace. Sequence: describe_import_target → create_import → the user opens the link and uploads → get_import_status. The result echoes `{ importId, uploadLink, expiresInSeconds, account, section }`, plus `subscribedBefore` when a subscription had a column, and a `note` when that count could not be read — reuse `account`/`section` on get_import_status. `updateExistingProfiles` is required and has no default: it decides whether people already in the account, matched on email, are updated from the file or left exactly as they are. `subscriptions`, chosen from describe_import_target's `subscriptionHint`, opts the imported profiles in — this creates GDPR opt-in consent. Omitting a subscription's `column` subscribes everyone in the file; giving one subscribes only the rows whose cell in that column is 1. `resubscribeIfOptedOut:true` re-subscribes profiles who previously opted out of the chosen subscription(s) (default false). A subscription's `column` must contain 1 for the people to subscribe and 0 (or empty) for everyone else; any other value — `yes`, `Y`, `true` — subscribes nobody at all, with the import still reporting complete success and no error naming the column. What can be checked before calling depends on whether the file's contents are visible to the model: read from disk, or attached by the user, and the column's actual values can be checked before calling. Otherwise the column cannot be checked at all, the 1/0 rule can only be stated, not verified, and calling proceeds on the user's word for what the column holds. A column holding anything other than 1/0/empty does not work as a subscription column at all — the fix is to subscribe everyone instead, or to fix the file. The column's values are never normalised, translated or fixed by the model or by the engine — neither touches the file's contents. When any subscription carries a `column`, the result carries `subscribedBefore` — the count and name of each such subscription, taken at the only moment it can be, before the file is even uploaded; get_import_status compares it against a fresh after-count as evidence of whether the subscribing worked. It can be absent even then: the count is a diagnostic that never blocks the import — it is read alongside creating the import, under a fixed ~10s budget — so if it could not be read in that time the import still goes ahead and the result carries a `note` saying the baseline is missing and that count_subscribed is the manual way to compare before against after. If this is refused for too many active imports, the account is at the platform's concurrent-import limit — counted per account across all sections (only imports made through the API count, not ones started in the Apsis One interface); list_imports shows what is actually holding the slots. `keyColumn` and `attributeMappings` reference the file's column headings verbatim, and the upload itself never carries those headings to the model — the file goes from the user's browser straight to storage. A typed heading that differs from the real one by a space or a capital silently imports nothing into that attribute, with the failure surfacing later with no explanation; reading the file directly (when the user has attached it) avoids that risk, though the user can also simply state the headings. This call commits the import: there is no separate preview step. The result's first text block is text written for the user, not the model: it carries the upload link as a markdown link, on its own line, and the import cannot proceed until the user opens it — a paraphrase like "I've started the import" loses the one thing the user needs in order to act. The link is already short and points at a stored upload authorisation, so an altered character resolves to nothing, and the page's only recourse then is asking for a new link. Calling this tool uploads nothing: the presigned fields in the link are for the user's browser to form-POST to storage, not for a request from the model, and the file's contents never pass through this call. get_import_status returns at once with `awaitingUpload:true` while the file has not arrived, and only blocks (up to ~45s) once the file is actually being processed; `awaitingUpload` means the user has not opened the link yet, which is not a failed import. On `expired` the link has lapsed and there is no refresh — a fresh one comes from calling create_import again.
create_import
Begins building a segment: your first call, and the gate to aligning with the marketer on who they want to target before you build anything. Mechanics: omit `section` to list the account's sections (a sole section is auto-selected silently; with several, pick one and call again). With a `section`, returns its attributes and engagement events — each with an id, human-readable `name` (match the marketer's phrasing against this, e.g. "loyalty tier" or "VIP"), its data type, the operators legal for that type, and `caseInsensitiveOperators` (the operators that can be made case-insensitive) — so you can build a schema-valid AQLContainer predicate using the field's `id`. Both operator lists come back empty for engagement events; that does not mean the event is unusable: an event's `type` is a structure listing the fields inside it (an email click, for example, carries `activityId`, `title` and `url`), and a predicate targets one of those fields by setting its `key` to that field's name, with the string operators for a field listed as "string" — this is how a condition is scoped to one campaign (`key: "activityId"`) or to one link of it (`title` or `url`). Only use a field name the event's `type` actually lists. For any other field whose type this release does not recognise, an empty list means the field cannot be used in a condition at all, not that every operator is allowed. Case-insensitive matching: for a text (string) condition, set `insensitive: true` on the predicate alongside `eq`/`neq`/`contains`/`starts`/`ends` and the match ignores capitalisation regardless of case ("gold" also matches "Gold" or any other case variant). Prefer this by default for values a person typed or a marketer names — tiers, cities, names, categories — because stored capitalisation is inconsistent and an exact-case match silently drops people; keep it case-sensitive only when the marketer signals case matters (e.g. a code or an all-caps token). Case-insensitive matching can change who matches without the condition showing it ("matching their loyalty tier regardless of capitalisation"). A case-insensitive "is not" also includes people who have no value set for that field at all, which the marketer may not intend.
describe_segment_target
Apsis Vega ChatGPT Plugin FAQ
How the directory, categories and Discoverability Score work.
Read the methodologyHow do I improve Apsis Vega's ChatGPT Plugin discoverability?
The levers are the listing surface agents actually read: names, descriptions, keywords, tool metadata, and registry health. Which lever matters depends on where discovery breaks, which is what continuous measurement shows.
Where is this profile measured?
This profile uses the geography attached to the latest public registry snapshot: US. Locale tags are intentionally omitted.


