> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cogniagent.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom actions

> When no ready-made action fits, call any part of a connected app's API with the connection you already have, test it, and save it for your team

Every app in the catalog comes with ready-made actions, but no list covers everything an app can do. A custom action fills the gap: you describe one request to the app's API, and it runs on the connection you already have.

You never type a password, API key or token. The connection signs the request, the same way it signs the app's ready-made actions.

## When to use it

* **The action you need is missing.** You want to archive a deal, add a tag or read a report, and the app's action list has nothing for it.
* **An action exists but lacks an option.** The API takes a field or a filter the ready-made action doesn't offer.
* **Your team makes the same call in many places.** Save it once, and it sits beside the app's own actions for everyone in the workspace.
* **Code or AI needs to reach further.** A code step or an AI step can call any part of the app's API, not only the actions you enabled.

> [!NOTE]
>
> A custom action reaches only apps you have connected, with that connection's own sign-in. For a service you have no connection to, or one you sign in to with your own key, use the [HTTP Request](/nodes/actions/http-request.md) node.

## How it works

You choose the method and the address inside the app's API, and add the values the call needs. CogniAgent fills in your workflow values, checks the request, and sends it with your connection's credentials.

```mermaid theme={null}
flowchart LR
    A[Your request<br/>method, path, values] --> B[CogniAgent fills in<br/>and checks it]
    B --> C[Your connection<br/>signs it]
    C --> D[The app's API]
    D -->|status and data| E[Next steps]
```

The request can only go to the app's own API. Headers that carry credentials are refused, so nobody can swap in another account or send your connection's access somewhere else.

## Which apps support it

Most apps in the catalog do. When you open an app's action list, the list ends with **Build a custom action**.

Some apps can't take one: apps that work through an MCP server and have no web API to call, apps whose API the connection can't sign (a database, a file server or a cloud API that needs signed requests), and some built-in apps. For those, the row stays in the list, marked **Not available**, with the reason in plain words, so you never wonder where it went.

> [!NOTE]
>
> For a few apps, support depends on the account. If the app can't take the request, your first test says so: "This app cannot take this request."

## Build a custom action

**1. Open the builder**

Add an [Integration Action](/nodes/actions/integration-action.md) step and choose the app. Under **Choose an action**, click **Build a custom action** at the bottom of the list: "Call any HubSpot endpoint with this connection".

Searching works too. When no action matches your search, the list says so and offers **Build a custom action**. Your search words are then waiting in **Help me build it**, and they also fill in the name if you save the action.

**2. Pick the connection**

Choose the connection that signs the request. Every call runs as that account, so it needs the rights the call needs.

**3. Set the method and path**

Under **Request**, pick the method and type the path, for example `/crm/v3/objects/deals`. Or let [Help me build it](#help-me-build-it) fill it in, or [paste a cURL command](#import-a-curl-command) from the app's API docs.

**4. Add parameters, headers and a body**

Use the **Params**, **Headers** and **Body** tabs for what the call needs. Each value takes `{{…}}` workflow values.

**5. Send a test**

Under **Test**, click **Send test request** and check the answer before the workflow ever runs. See [Test the request](#test-the-request).

### Method and path

The method says what the request does:

| Method | The menu says | Use it to |
| - | - | - |
| `GET` | Reads data | Look something up |
| `POST` | Creates or sends | Create a record, send a message, run a search some APIs only offer as POST |
| `PUT` | Replaces | Replace a whole record |
| `PATCH` | Updates | Change some fields of a record |
| `DELETE` | Deletes | Remove a record |

The path is the part of the address after the app's API address. Under the path, a chip shows where it goes:

* **Most apps** show their API address, for example `api.hubapi.com`. Type a path that starts with `/`. A full `https://` address works too, as long as it is on the app's own domain.
* **Apps where each account has its own address** (your company's own Zendesk or Salesforce domain, for example) show "your Zendesk address". Use a path, never a full address: the connection knows the account's address.
* **A few apps have no base address.** The path field then expects a full `https://` address on the domain the builder names.

The second chip, "Signed by your HubSpot connection", is a reminder that the credentials come from the connection.

### Params, headers and body

* **Params** are the query values added to the address, such as `limit` = `50`. Click **Add parameter** for each one.
* **Headers** are extra headers the API asks for, such as an API version header. The first row, **Authorization**, is locked: "Added by your HubSpot connection". A header that carries credentials is refused with "The connection signs this request, so this header is not allowed". That covers `Authorization`, `Cookie`, and any header named like a token, secret, password or API key.
* **Body** is the data you send, written as JSON. It is available for `POST`, `PUT` and `PATCH`. **Format** tidies it up.

### Import a cURL command

API docs often show each call as a cURL command, and your browser's developer tools can copy any request as one ("Copy as cURL"). Click **Import cURL** beside **Request**, paste the command and click **Import**. Pasting a command straight into the path field works too.

The request fills in: the method, the path (the app's address is taken off, so only the path stays), the parameters, the headers and the JSON body. **Imported from cURL** says what changed, with **Undo**:

* **Credentials are left out.** An `Authorization` header, a user and password (`-u`), cookies and API-key headers are dropped, because your connection signs the request. The panel lists what it left out.
* **Browser headers are left out**, such as `user-agent` and `sec-ch-ua`.
* **Form fields become JSON fields.** Custom requests send JSON, so check that the app accepts JSON.
* **A command for another address keeps only its path**, and the panel names the address it called.

A command that uploads a file (`-F`, `-T`), reads its body from a file (`-d @body.json`), or sends a method other than `GET`, `POST`, `PUT`, `PATCH` or `DELETE` is not imported, and the panel says why.

### Use workflow values

The path, the parameter and header values, and the body all take `{{…}}` values from earlier steps, the same way other step fields do (see [Passing information between nodes](/nodes/overview.md#passing-information-between-nodes)). For example, a body that creates a HubSpot deal from a form submission:

```json theme={null}
{
  "properties": {
    "dealname": "{{webhook_1.body.company}} renewal",
    "amount": "{{webhook_1.body.amount}}"
  }
}
```

A value that fills a whole field keeps its type. If `amount` is the number `1200`, the app receives the number, not the text `"1200"`.

A test has no workflow run to take values from, so the test area lists every `{{…}}` value under **Test values**:

> A test has no workflow run, so give each value here. Runs use the real ones.

When earlier runs left an example value, it is already filled in.

## Help me build it

Describe the call in your own words, and an AI assistant drafts the request for you. It opens on its own when the request is empty. Otherwise, click **Help me build it** beside **Request**.

**1. Say what the app should do**

For example: "archive the deal whose id comes from the trigger". While it works, the panel reads "Reading the HubSpot API": it looks up the app's own API documentation.

**2. Check the draft**

The assistant fills in the method, path, parameters, headers and body, and says in a sentence or two why. Its answer is marked **Applied to the request**, with **Undo**, and often an **API docs** link to the page it used.

**3. Ask for changes**

Reply in your own words, such as "also set the close date to today". Each answer replaces the draft, and **Undo** brings back the one before.

If one of the app's ready-made actions already does the job, the assistant says so and offers **Use “Archive deal” instead**, which switches the step to that action.

The assistant never sends anything. Its drafts follow the same rules as yours (no credential headers, only the app's own domain), and you still press **Send test request** yourself. It also suggests a name and a description, which fill in the save form.

Each answer uses AI, so it uses credits like the platform's other AI helpers.

## Test the request

**Send test request** sends the request once, for real, with the connection you picked.

* A `GET` goes out at once.
* Any other method asks first: "This sends a real POST to HubSpot as Sales team. It can change data there." Click **Send it** or **Cancel**.

> [!WARNING]
>
> A test is a real call. A test `POST` creates a real record, and a test `DELETE` deletes one. Test against data you can afford to change.

The answer shows its status (`200`, `404`), how long it took, its size, and the body. **Headers** opens the response headers that are kept (see [Limits](#limits-and-rules)).

When the app answers with a file, such as a PDF or an image, the test shows the file's name, type and size, and **Open** opens it. A workflow run keeps the file too: see `file` in [What the step returns](#what-the-step-returns).

When the request could not be sent, the panel says why, with a hint where one helps:

| What happened | Hint |
| - | - |
| The connection expired | Reconnect the app in Connections, then try again. |
| Too many requests | Wait a moment, then try again. |
| The app took too long | The app did not answer within 30 seconds. |
| The app can't take custom requests | This app cannot take this request. |

After a failed test, **Fix with AI** sends the status and the app's answer to [Help me build it](#help-me-build-it), which proposes a corrected request. You review it and send the next test.

To send a test you need a confirmed email address and permission to use the connection.

## When the app answers with an error

By default, a `4xx` or `5xx` answer fails the step, like any Integration Action. The failed step shows the status and the start of the app's answer, for example `HubSpot answered 404: {"message":"Object not found"}`, and the rest of the workflow handles it as any failure.

To handle errors yourself, open **More options** and turn on **Continue when HubSpot answers with an error**:

> Off: a 4xx or 5xx answer fails this step. On: the step passes the status and body on to the next steps.

Then branch on the answer with a [Condition](/nodes/logic/condition.md), for example on `{{external_api_1.ok}}` or `{{external_api_1.status}}`.

## What the step returns

**data** `any`

The app's answer: JSON when the app sends JSON, otherwise text. Read fields with `{{external_api_1.data.id}}`. Empty when the app answers with a file.

**file** `object`

A file the app answered with (a PDF, an image, an export), saved with the run: its `name`, `size` and `mimeType`. Pass `{{external_api_1.file}}` to a later step that takes a file, such as an [HTTP Request](/nodes/actions/http-request.md) upload.

**status** `number`

The HTTP status the app answered, such as `200` or `404`.

**ok** `boolean`

`true` when the status is in the 200s.

**headers** `object`

The useful response headers, such as the content type, paging links and rate-limit headers. The others are dropped.

**request** `object`

The method and path that were sent, without the query values.

## Save it as a reusable action

A request you build in one step can become an action anyone in the workspace can pick, right beside the app's ready-made actions. Agents, AI steps, coworkers and code can use it too.

**1. Open the save form**

At the bottom of the builder, open **Save as a reusable action**: "Everyone in this workspace can pick it; agents and code can use it too."

**2. Name it**

Give it a **Name** (up to 80 characters), such as "Archive deal". Fill in **When to use it** with what it does, in one sentence. Agents read this to decide when to call it.

**3. Check the inputs**

Each workflow value in the request becomes an input, found for you and named after the value: `{{trigger.deal_id}}` becomes `deal_id`. Pick each input's type (**Text**, **Number**, **Whole number**, **True or false**, **List** or **Object**) and whether it is **Required**. Input names use lowercase letters, digits and `_`.

**4. Mark a read-only POST**

Only for a `POST` that changes nothing, such as a search, tick **It only reads data (for example a search)**. Coworkers then use it without asking first. Leave it off when the call changes anything.

**5. Save**

Click **Save action**. The step now runs the saved action and passes each input the same workflow value as before, so it keeps working unchanged.

Once saved, the action shows up:

* **In workflow steps**, under **Your custom actions** at the top of the app's action list, with its method and a **Custom** tag.
* **For AI steps, agents and coworkers**, in the app's action list, marked **Custom**. Switch it on like any action.
* **In code**, as a function named after the action, once you enable it on the code step. See [Use it from code](#use-it-from-code).

A saved action belongs to one app, and everyone in the workspace can see, use and change it.

### A step that runs a saved action

The step shows the action's name, its **Custom** tag, its method and path, and **Edit**. Below that come the connection, one field per input (each takes `{{…}}`), **Continue when HubSpot answers with an error**, and a test.

### Edit or delete a saved action

Open any step that uses the action and click **Edit**. Before you change anything, the panel says what uses it:

> Used in 3 workflows, 1 agent and 2 coworkers. A change reaches all of them on their next run.

You can change the name, **When to use it**, the request, the inputs (**Add input**, change a type, remove one) and, for a `POST`, whether it only reads data. In the saved request, inputs are written as `{{args.<input name>}}`, for example `/crm/v3/objects/deals/{{args.deal_id}}`. Click **Save changes**, and every step, agent and coworker that uses it runs the new version from its next run.

**Delete action** asks you to confirm and names what uses it. After deleting:

* Steps that used it can't run, and show "“Archive deal” was deleted, so this step cannot run. Choose another action."
* Agents, AI steps, coworkers and code no longer get it.

## Use it from code

When you bind an app to an [Execute Code](/nodes/actions/execute-code-integrations.md) step, and the app supports custom actions, the code gets one more function, with nothing to enable:

```python theme={null}
integrations.<app>.request(method, path, params=None, json=None, headers=None)
```

It is listed under **Integration functions** with the app's other functions, and the code step's integration panel shows the exact call under **Any endpoint from code**.

It returns a response with:

| | |
| - | - |
| `.status` | The HTTP status the app answered |
| `.ok` | `True` for a status in the 200s |
| `.data` | The answer: JSON as Python dictionaries and lists, otherwise text |
| `.file` | A file the app answered with, saved with the run (`name`, `size`, `mimeType`), else `None` |
| `.headers` | The useful response headers |
| `.raise_for_status()` | Raises `IntegrationError` with code `http_<status>` unless `.ok` |

A `4xx` or `5xx` answer is returned, not raised, so your code can read the app's reason. `IntegrationError` is raised only when nothing could be sent: the request was refused, the app does not support it, the connection is missing, the workspace hit its rate limit, or the app did not answer in time.

```python theme={null}
# Read every HubSpot contact, 100 at a time
contacts = []
after = None
while True:
    params = {"limit": 100}
    if after:
        params["after"] = after
    response = integrations.hubspot.request("GET", "/crm/v3/objects/contacts", params=params)
    response.raise_for_status()  # stop on a 4xx or 5xx
    contacts.extend(response.data["results"])
    after = response.data.get("paging", {}).get("next", {}).get("after")
    if not after:
        break

output_data({"count": len(contacts)})
```

> [!NOTE]
>
> If the app already has a ready-made action whose function is called `request`, it keeps that name, and the custom call is `integrations.<app>.api_request(...)` instead. The editor shows the name to use.

A saved custom action works like any other function once you enable it on the code step: `integrations.hubspot.archive_deal(deal_id="1882")`. It returns the app's data, and a `4xx` or `5xx` answer raises `IntegrationError` with code `http_<status>`.

Custom calls count toward the step's [200 calls per run](/nodes/actions/execute-code-integrations.md#limits), like every app call.

## Use it from AI steps and agents

An [LLM step](/nodes/actions/llm-integrations.md), a Human Step or a [Chat & Voice Agent actor](/conversation-flows/build/capabilities.md#integrations) can call the app's API too, when you allow it. In the app's integration panel, above the action list, **Custom API calls** has three settings:

| Setting | The panel says |
| - | - |
| **Off** (default) | Only the actions checked above. |
| **Read only** | It can also read any endpoint (GET) when no action fits. |
| **Read and write** | It can also call any endpoint, including ones that change data. It does not ask first. |

With **Read only** or **Read and write**, the AI gets one more ability for that app: a request to any part of its API, signed by the same connection. It is told to use it only when no action fits, and never to add credentials.

* **Read only is enforced.** The AI is offered `GET` alone, and any other method is refused before it is sent.
* **The AI reads the app's answer, errors included,** so it can see why a request failed and fix it.
* **Long answers are cut** to the first 100,000 characters, with a note telling the AI to ask for less.
* With **Custom API calls** on, you can save the app with no action checked.

> [!WARNING]
>
> **Read and write** lets the AI change data in the app without asking. Start with **Read only**, and enable write access only where the step needs it.

Coworkers have the same setting, and they ask you before any call that changes data. See [Connecting your apps](/cowork/capabilities/connecting-your-apps.md#custom-api-calls).

## Build them with an AI assistant

Over [MCP](/mcp-server.md#custom-actions), an AI assistant such as Claude or Cursor can find out whether an app takes custom requests, add a custom request step, and create, update or delete saved custom actions with `manage_custom_action`.

## Cost

* A workflow step that runs a custom action costs the same as an Integration Action step with a ready-made action.
* A custom call from code, an AI step, an agent or a coworker costs the same as a call to a ready-made action.
* **Help me build it** uses AI, and each answer uses credits.

## Limits and rules

* **Methods:** `GET`, `POST`, `PUT`, `PATCH` and `DELETE`.
* **Body:** JSON only (an object or a list), for `POST`, `PUT` and `PATCH`, up to 5 MB.
* **Answer:** up to 25 MB. JSON comes back as data, text as text, and a file is saved with the run.
* **Time:** the app has 30 seconds to answer.
* **Rate:** up to 60 custom requests a minute per workspace, shared by workflow steps, tests, code, AI steps, agents and coworkers. Past that, the request is refused with "Too many custom requests from this workspace. Try again in N s."
* **Address:** a path on the app's API, or a full `https://` address on the app's own domains. Nothing else.
* **Headers:** never `Authorization`, `Cookie`, API keys or other credentials, and none of the headers the browser or the network sets (`Host`, `Origin`, `Content-Length` and the like). Your connection signs every request.
* **Response headers:** only the useful ones are kept: content type, length and disposition, `Link`, `Location`, `ETag`, `Last-Modified`, `Retry-After`, request ids and rate-limit headers. Cookies and the rest are dropped.
* **Size:** up to 50 parameters and 50 headers per request.
* **Saved actions:** a name of up to 80 characters, a description of up to 500, and up to 30 inputs.

## Related

> **[Integration Action](/nodes/actions/integration-action.md)**
>
> Run one of an app's ready-made actions.

> **[Execute Code integrations](/nodes/actions/execute-code-integrations.md)**
>
> Call your apps from Python, in loops and batches.

> **[LLM Integrations](/nodes/actions/llm-integrations.md)**
>
> Let AI decide which app actions to call.

> **[HTTP Request](/nodes/actions/http-request.md)**
>
> Call any web service with your own credentials.

> **[Connecting your apps](/cowork/capabilities/connecting-your-apps.md)**
>
> Custom API calls for coworkers, and how they ask first.

> **[Connect MCP](/mcp-server.md)**
>
> Build steps and saved actions from your AI assistant.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.