Description
Integrate Intake over its stateless responses API so unstructured customer input (an email, support ticket, PDF, spreadsheet, CSV, or free text, including an inbound quote or purchase order) becomes one Cart, Quote Request, or Shopping List. Use when connecting that input to Intake (also called the Intake Agent), including outputType, file upload, and writing the created id back to the channel. Not the Merchant Center Intake UI, and not ongoing ERP, CRM, or transactional-email sync (see commercetools-integrations).
Installation
In any Claude Code session, run the following commands one at a time:
-
Add the commercetools AI marketplace:
/plugin marketplace add commercetools/commercetools-ai-plugins -
Install the commercetools AI plugin (Skills + Subagents + MCP):
/plugin install commercetools@commercetools
If you've updated the plugin or installed it in another window and need the current session to pick up the latest version:
/reload-plugins
commercetools/commercetools-ai-plugins. Then, click on the plugin and click Install.Instructions Included
Intake
The adapter owns three decisions Intake will not make: which entity to create, which Customer the message is for, and whether this inbound event is a new request or a reply to an entity already created.
Out of scope
- Building or automating the Merchant Center Intake UI. Use that UI once, before writing the adapter, to confirm the same prompt creates the entity. The UI can ask a clarifying question. This endpoint cannot.
- Connect connectors, transactional email, and CRM sync. Those belong to commercetools-integrations.
- Quote negotiation after a Quote Request exists (Staged Quote, Quote, Order). That is the Quotes API.
Workflow
-
Docs search (required, run first) — The first time you use this skill in a session you must run this before answering. It gathers the latest verified documentation as your primary grounding, filtered to the products this skill covers. Use this script for documentation search while working with this skill; the Knowledge MCP covers everything else. Always confirm details against retrieved documentation rather than the skill text alone:
node "<this skill's directory>/scripts/docs-search.mjs" \ --query "Intake responses API Cart Quote Request Shopping List file upload" \ --app-name "<host app: claude-code, claude-chat, cursor, codex, copilot — or the host's own name>" \ --model "<current-model>" \ --commercetools-project-key "<if known>" \ --commercetools-region "<if known>" \ --limit 10Pass the commercetools project key and region (as inapi.{region}.commercetools.com) only if already in your context; otherwise omit both. Never search files or ask the user for them. -
Fetch the REST schema before writing a request or parsing a response. Run:
node scripts/openApi-schemata.mjs \ --resource-name "api-AgentResponsesSuccess" \ --app-name "<host app: claude-code, claude-chat, cursor, codex, copilot — or the host's own name>" \ --model "<current-model>"The output is the OpenAPI specification for that resource. If the name is not recognized, the script prints valid names. Re-run withapi-Cart-write,api-QuoteRequest, orapi-ShoppingListwhen the follow-up is an update to the entity Intake created, not another Intake call. -
Confirm the Project can accept the call. Intake is enabled for the Project (otherwise
FeatureDisabled). The API client has themanage_intake_agent:{projectKey}scope. Product Search is enabled (otherwiseProductSearchNotEnabled). The buyer already exists as a Customer. Intake looks a Customer up by email and does not create one. -
Run the same prompt in Merchant Center before writing the adapter. Paste the prompt and any file into Merchant Center Intake. A failure there is the Project, catalog, or Customer. A success there and a failure from the API is the request the integration sent. A success that needed a clarifying question does not count: the prompt that produced the entity is the prompt the API body must contain.
-
Choose
outputType. Read choose-output.md. The field is required. Intake never infers it from the message. -
Map the channel into one request. Read call-responses.md, then the channel file:
- Mailbox: email.md
- Zendesk: zendesk.md
- Any other drop (a form, a drive folder, another helpdesk): the same mapping in call-responses.md. A new channel is a new normalizer, not a new Intake contract.
-
Call once per new request, and remember that you did. The endpoint has no idempotency key. Conditionally insert the channel's own id (email
Message-ID, Zendesk ticket id) with the normalized request as queued work, acknowledge the delivery, then mark the row as a call with an unknown outcome immediately before the POST. On success, store that id againstthreadId,entity.id, and, for a Quote Request,cartId. Recovery may send a queued row that was never marked. It must not resend a marked row. A second POST creates a second entity. -
Handle the outcome and write it back using handle-outcome.md. A
201can still carry warnings. A400names what was missing. Do not answer a400by calling again with the same body. -
Route a later message on the same thread by the stored outcome. An entity already stored is updated with the normal Cart, Quote Request, or Shopping List APIs. A confirmed no-write failure (a
400that created nothing) allows one corrected Intake attempt when the reply supplies the missing input. A pending queued row, a call with an unknown outcome, or a partial creation (QuoteRequestCreationFailedwithcartId) is resolved from that stored state. Do not POST again to finish it.
Checklist
- The same prompt created the entity in Merchant Center before the adapter was written. A clarifying question in that UI does not count
-
outputTypewas chosen by the integration, not left for Intake to guess - The prompt states the buyer's email, and for a Cart or Quote Request a shipping country, in plain text
- A Shopping List name is stated in the prompt or a file. Intake does not invent one
-
businessUnitandstoreare passed as resource identifiers when the channel knows them - Uploads use the
filespart, at most 3 files, only allowed content types - The channel event id and the normalized request are stored as queued work before the webhook is acknowledged. A call is marked as an unknown outcome immediately before the POST, then stored against
entity.idandthreadId. Recovery sends unstarted work and does not resend a call that may have completed - Warnings and the specific
400code are written back to the channel - A follow-up on the same email thread or ticket follows the stored outcome: update an existing entity, allow one corrected Intake attempt after a confirmed no-write failure when the reply supplies the missing input, and resolve a pending, unknown, or partial-creation outcome without a blind retry
References
Call the responses endpoint
POST /{projectKey}/agents/intake/v1/responses on https://api.{region}.commercetools.com. Reference: Intake. Schema: api-AgentResponsesSuccess via openApi-schemata.mjs.manage_intake_agent:{projectKey}. A 401 is invalid_token. A 403 is insufficient_scope. Both use AuthErrorResponse, not the Intake error envelope.What the body can express
| Field | Role |
|---|---|
outputType | Cart, QuoteRequest, or ShoppingList. Required. Never inferred. |
locale | BCP-47 language tag of the message, such as en-US or de-DE. Required. This is the language of the Shopping List name and of product-name matching. It does not set the shipping country. |
prompt | The natural-language input. Required on application/json. Optional on multipart when at least one file is attached. |
businessUnit | Optional resource identifier. Pass key when the channel knows the unit. |
store | Optional resource identifier. Same rule. |
prompt and from files. If the channel already knows one of them, write it at the top of the prompt in plain sentences. Extraction then does not have to recover it from a signature or a ship-to block:{
"outputType": "Cart",
"locale": "de-DE",
"businessUnit": { "typeId": "business-unit", "key": "acme-west" },
"store": { "typeId": "store", "key": "west-coast" },
"prompt": "Customer email: jane.doe@example.com. Shipping country: DE.\n\nBitte 12x ABC-123 und 4x XYZ-789 bis Freitag."
}
locale stays the language. Shipping country: DE is what satisfies the country requirement. A German mailbox with locale: de-DE and no country in the text still fails with MissingCountry for a Cart or Quote Request.Why the structured fields exist
BusinessUnitUnresolved, BusinessUnitAmbiguous, StoreUnresolved, and StoreAmbiguous. Pass businessUnit and store when a mailbox route, a ticket field, or an organization mapping already decided them.BusinessUnitUnresolved. A pinned Store the Customer cannot order from through that unit fails with StoreUnresolved. Map the channel's organization to the commercetools key. Do not send the external organization's id and expect Intake to translate it.businessUnit rather than hoping the text is clear enough.BusinessUnitLimitExceeded means the Customer's units could not be listed. Pin businessUnit (and store if it is still ambiguous) and call again only if the first call did not create an entity.StoreDistributionChannelsUnsupported. Choose a Store with zero or one.Files
multipart/form-data when there is a document. One payload part, JSON, content type application/json. Every file under the part name files. Any other part name is InvalidField.message/rfc822), Markdown, HTML. Images are not allowed. A file over the size limit is ContentTooLarge (413).Prefer the purchase order over logos and signature images. Three slots is the whole budget.
.eml is one of those three files. Intake reads its From as the customer email and also reads attachments nested inside it. Inline related images, including most signatures, are skipped and do not fail the file. A non-related attachment of a disallowed type fails the whole file as FileNotProcessed. In that case, put the body in prompt and attach only the document files..eml when the message is the buyer's own mail and its attachments are all allowed types. The From address is then the Customer email, so the prompt preamble is only needed for country, list name, or a Business Unit the headers do not carry.One call, no resume
threadId on the response identifies that run for support. It is not a handle for a second call.Idempotency belongs to the adapter
The endpoint will happily create two Carts for one email. Two overlapping deliveries that both see an empty key both POST, so the request has to be stored before the HTTP acknowledgement returns. An in-progress marker alone cannot recover that request: if the process exits after acknowledging and before the POST, the row stays in progress and every redelivery is discarded.
- Conditionally insert the channel event id together with the normalized request, as queued work that has not been sent. If that insert does not win — the id is already queued, a call is already in an unknown state, or the id already has an
entity.id— acknowledge the duplicate so the channel stops redelivering, and stop. Do not call from this delivery. The stored row remains the copy recovery runs. - Acknowledge the delivery. The queued request is now the only copy that can still run.
- Mark that row as a call with an unknown outcome immediately before the POST, then call Intake once. Recovery may send a queued row that was never marked this way. It must not send a row already marked: that POST may have completed, and a second call creates a second entity.
- On
201, storeentity.id,entityType,threadId, andcartIdwhen present, against the same id. - On a
400that names missing input, store the failure against the same id so a redelivery does not repeat it. Correct the input only when you have new information, then allow one new attempt.
ExtractionFailed (500) happens before a commercetools write. A single retry of that code is safe if the row still has no entity.id. QuoteRequestCreationFailed is not safe to retry: the Cart already exists. See handle-outcome.md.Choose the output
outputType is the integration's decision. Intake does not pick from the wording of the email. If the business has not decided, ask before generating the client. The required inputs per type are in Required information.| The request is… | outputType | The prompt must also contain |
|---|---|---|
| Something to price and fulfill at the Customer's entitled prices | Cart | Buyer email, shipping country, at least one product reference |
| A request for terms (volume, a split delivery, "please quote") | QuoteRequest | The same as a Cart. The Customer must already exist; the created Cart has to carry that verified Customer |
| A named list to buy later, or a list that may include unpublished products | ShoppingList | Buyer email and a name for the list. Country is not used |
NoLineItemsExtracted. Some unmatched products are a warning, not a failure. See handle-outcome.md.Cart
entity is a Cart in REST form.locale and not from the Customer record. A marketplace mailbox that only receives German orders should still put Shipping country: DE in the prompt.Quote Request
201 body includes entity (the Quote Request) and cartId (the Cart it was created from). Keep both.QuoteRequestCreationFailed and it carries cartId of that Cart. Do not call Intake again to "finish" it. That creates another Cart. Create the Quote Request from cartId with the Quote Requests API. Deleting the Cart is the other option, and it removes customer data. Show the exact proposed deletion request and obtain the user's explicit confirmation before executing it.QuoteRequestCreationFailed is the later case, where the Cart exists and the Quote Request does not. Create or correct the Customer first, then retry only if no cartId was stored.After the Quote Request exists, seller follow-up is the normal quote flow, not another Intake call.
Shopping List
locale (or a project or store language) is looked up against staged data, and the line item is stored with published: false. Partial names do not match. Details: Unpublished Products in a Shopping List.Create a shopping list called "Q3 restock". Intake does not invent one. A missing name is MissingShoppingListName and nothing is created. When the channel has a natural name (the email subject, the ticket subject), state that name in the prompt rather than asking the model to title it.locale. A description in the input is stored the same way.Country is not required and is not used for pricing.
Message-ID. Use the same sequence as call-responses.md: conditionally insert that id with the normalized request as queued work, acknowledge the provider delivery, then mark the row as a call with an unknown outcome immediately before the POST. A redelivery of the same Message-ID loses the insert and must not call Intake again. Recovery sends a queued row that was never marked, and does not resend a marked row.Which address is the Customer
orders@your-domain.com) and not the address of whoever forwarded it internally.| What you received | Customer email |
|---|---|
| The buyer's own message | The From address, if that address is a Customer in the Project |
| A forward | The original From inside the body, not the forwarder's |
| A helpdesk or automation notification | The buyer named in the body, not the notification sender |
Customer email: jane.doe@example.com. When you upload the buyer's own .eml, Intake already treats From as the customer email. A forwarded or notification .eml will treat the wrong From as the customer, so do not upload those. Use the parsed body plus an explicit preamble instead.Message-ID. Intake will not create the Customer for you.What to send
State, in the prompt, anything the route already knows:
Customer email: …Shipping country: DEfor a Cart or Quote Request. A dedicated country mailbox is not a country unless you write it out.localedoes not carry it.- For a Shopping List,
Create a shopping list called "{subject}"using the subject when the body never names the list.
Then the body.
Attachments:
- Keep PDF, CSV, XLSX, DOCX, HTML, and plain text. Drop images. The cap is 3 files, 20 MB each.
- Upload the buyer's raw
.eml(message/rfc822) when its non-related attachments are allowed types. Inline related images, including most signatures, are skipped and do not fail the file. A non-related attachment of a disallowed type fails the whole file asFileNotProcessed, and you may getNoLineItemsExtractedif nothing else was readable. - Otherwise put the body in
promptand attach the purchase-order files asfilesparts.
outputType still comes from choose-output.md, not from words like "quote" in the subject. A business rule may map those words. That rule lives in the adapter.Replies
In-Reply-To and References to a Message-ID you already stored.- A match means this mail is about an entity you already created. Update that Cart or Shopping List with its own API, or hand the Quote Request to the quote flow. Do not POST to Intake. A second call creates a second entity and the buyer now has two baskets.
- No match means a new request. Call once.
MissingCountry, unmatched SKUs from a ProductsNotFound warning) or with the created id.Handle the outcome
201 with AgentResponsesSuccess: entityType, entity (the Cart, Quote Request, or Shopping List in REST form), threadId, optional warnings, and cartId only for QuoteRequest. warnings is omitted when there is nothing to report. An empty array is not the success shape.statusCode, message, errors, and threadId once the run has started. Parsing failures, before the run starts, have no threadId. Auth failures are a different envelope. See call-responses.md.threadId whenever it is present. It is the id support can find the run with.201 with warnings
The entity exists. Store it. Do not call again to "fill the gaps". That creates a second entity.
Warning code | What it means | What to do |
|---|---|---|
ProductsNotFound | Some requested products were not matched, or are not in the bound Store. products lists the identifiers. The matched lines are on the entity. | Tell the channel which identifiers were dropped. A person edits the entity or replies with a better identifier. Apply that as an update action. |
FileNotProcessed | One upload could not be read. fileName names it. Other input may still have produced the entity. | Tell the channel which file was skipped. If the purchase order was the skipped file, the entity may be incomplete. Review it before fulfilling. |
ProductsNotFound as a 400 is the other case: nothing matched, and no entity was created. Same code, different status. Branch on the HTTP status.400, nothing was created
entity.id.| Code | Fix in the adapter |
|---|---|
RequiredField | A request field is missing. outputType and locale are the usual ones. A JSON body also needs prompt. |
MissingCustomerEmail | The input never stated an email. Add Customer email: … to the prompt. |
InvalidInput | Message is No customer found with email "…". The address was stated and matches no Customer. Create the Customer, then allow one new attempt. |
MissingCountry | Cart or Quote Request only. Add Shipping country: XX. locale will not do this. |
MissingShoppingListName | Shopping List only. State the name in the prompt. |
MissingEntityType | You called without a resolved outputType. Choose one. Do not retry hoping the text implies it. |
NoLineItemsExtracted | No product reference in the text or the files. Often a FileNotProcessed on the only document. Fix the file and include a line in the prompt if you have one. |
ProductsNotFound | Every referenced product missed the catalog. No entity. Surface the identifiers. Do not retry unchanged. |
OutOfScope | The input had none of the cart facts (products, email, and country). The message is not an order request. Do not retry. |
BusinessUnitUnresolved | Name or pin a unit this Customer belongs to. A pinned unit they are not associated with produces this too. |
BusinessUnitAmbiguous | Several units, none selected. Pass businessUnit. |
BusinessUnitLimitExceeded | Too many units to list. Pass businessUnit. |
StoreUnresolved | Name or pin a Store this Customer can order from. |
StoreAmbiguous | Several stores. Pass store. |
StoreDistributionChannelsUnsupported | The Store has more than one distribution channel. Choose another Store. |
InvalidField | A multipart part was not named files or payload, or a field failed validation. |
FeatureDisabled | Intake is not enabled for the Project. This is a configuration problem, not a retry. |
A Cart may already exist
QuoteRequestCreationFailed means the Cart was created and the Quote Request was not. The error includes cartId. Store that id against the channel event. Then create the Quote Request from that Cart, or delete the Cart. Deleting it removes customer data. Show the exact proposed deletion request and obtain the user's explicit confirmation before executing it. A second Intake call creates another Cart and leaves the first one behind.ShoppingListCreationFailed leaves no Cart. It is Shopping List only, including the case where every requested product was dropped because the Store does not carry them.Nothing was written
ExtractionFailed means the run failed before a commercetools write. Branch on the code. One retry is reasonable when the channel event still has no stored entity id.ProductSearchNotEnabled is returned until Product Search is enabled for the Project. Retrying without that change fails the same way. Both codes are listed with the Intake errors.After a 201
entity.id. For a Quote Request also keep cartId. Later channel messages update that resource:- Cart: update actions, then freeze, order, or create a Quote Request from it.
- Quote Request: the quote workflow. The buyer-facing entity already exists.
- Shopping List: update actions, then add its line items to a Cart when the buyer is ready.
Deleting the entity and starting over is a new Intake call, and it needs a new channel event id or an explicit "retry allowed" flag. An automatic retry of the original event is how the deleted entity comes back as a duplicate.
Zendesk
When to call
order-request is the right gate. ticket.comment_added, or a trigger on every update, creates a new commercetools entity per comment.- Conditionally insert the ticket id with the normalized request, as queued work that has not been sent. If that insert does not win — the id is already queued, a call is already in an unknown state, or the id already has an
entity.id— acknowledge the duplicate so Zendesk stops redelivering, and stop. Do not call from this delivery. - Acknowledge the webhook. The queued request is now the only copy that can still run.
- Mark that row as a call with an unknown outcome immediately before the POST, then call Intake once, out of band. Recovery may send a queued row that was never marked. It must not send a row already marked.
Acknowledging before the insert lets two overlapping deliveries both see an empty key and both POST. Acknowledging a bare in-progress marker, with no stored request, drops the ticket if the process exits before the POST.
What to put in the request
.eml. Its From is the wrong Customer.Customer email: jane.doe@example.com.
Shipping country: DE.
Create a shopping list called "PO 4481", using the ticket subject when the description never names the list. Omit the country line for a Shopping List. The ticket description follows the preamble. See choose-output.md.locale is the ticket's language, not a guess from the SKUs.key as businessUnit. The same for Store. The external organization id is not a commercetools key. A unit the Customer is not associated with fails with BusinessUnitUnresolved.Attachments
content_url, content_type, and file_name. Download the file and re-upload it as a files part. content_url may be hosted outside Zendesk. Do not send Zendesk credentials to that host.Write the result onto the ticket
201, add an internal comment (public: false) that a merchant can read: entityType, entity.id, threadId, cartId for a Quote Request, and any warning codes with the unmatched product identifiers. Store entity.id and entityType on ticket custom fields. The comment is for humans. The fields are what the next delivery checks.400, comment the specific codes (MissingCountry, MissingCustomerEmail, ProductsNotFound, BusinessUnitAmbiguous) and the threadId if present. Do not close the loop by calling Intake again with the same body. Either a person supplies the missing fact and you allow one new attempt, or the ticket stays with the agent.Later comments
A genuinely new order from the same requester is a new ticket, which gets its own call.