# Management SDK - Intro

> Source: https://agilitycms.com/docs/dotNet/management-sdk-dotnet-intro

The Agility CMS Management SDK for .NET is a client for the Agility **Management API**. Use it to create, update, publish and delete content, pages, page models, models, containers, assets, locales, webhooks, URL redirections and more from your own .NET code. Version 2.0 covers every operation in the Management API.

The SDK is for *managing* content. To read published content on a website, use the Fetch API and its SDKs instead.

> **Using 1.x?** These articles cover version 2.0. Version 1.x targets .NET 6, so it runs on .NET 6 or later, and it gets fixes only. .NET 6 and 7 are already out of support, and .NET 8 and 9 reach end of support on November 10, 2026, so move to .NET 10 and SDK 2.0 for new and upgraded projects. The 1.x documentation and source are in the [1.0.12-beta release on GitHub](https://github.com/agility/agility-cms-management-sdk-dotnet/releases/tag/v1.0.12-beta), and the package is [Agility.Management.SDK 1.0.12-beta on NuGet](https://www.nuget.org/packages/Agility.Management.SDK/1.0.12-beta). Use 1.0.12-beta or later: earlier 1.x versions remove every zone's default components when they save a page model. To upgrade, see [Migrating to 2.0](https://agilitycms.com/docs/dotNet/management-sdk-dotnet-migrating-to-2).

## Use cases

- Importing content from external systems
- Syncing content with third-party platforms
- Bulk updating and publishing content
- Implementing custom approval workflows
- Managing pages, models and assets programmatically

## Requirements

- .NET 10
- An Agility CMS instance, and its instance GUID
- A Personal Access Token, or an OAuth sign-in (see [Authentication](#authentication))

## Install

Add the package from NuGet:

```bash
dotnet add package Agility.Management.SDK
```

The source is on [GitHub](https://github.com/agility/agility-cms-management-sdk-dotnet).

## Quick start

```csharp
using Agility.Management.Sdk;

using var client = new AgilityManagementClient(new AgilityManagementOptions
{
    AccessToken = Environment.GetEnvironmentVariable("AGILITY_TOKEN"), // a Personal Access Token
});

var guid = "1234abcd-u";   // your instance GUID: every instance-level call takes it first

// Read, change and save a content item, then publish it.
var item = await client.Content.GetContentItemAsync(guid, "en-us", 42);
item.Fields!["title"] = "Updated from .NET";

var saved = await client.Content.SaveContentItemAsync(guid, "en-us", item);      // waits for the save to finish
await client.Content.PublishContentItemAsync(guid, "en-us", saved.ItemId!.Value); // saves land in Staging
```

## Creating the client

Create one `AgilityManagementClient` and reuse it. It's thread-safe, and one client works with any number of instances: pass a different instance GUID to each call.

```csharp
using Agility.Management.Sdk;

using var client = new AgilityManagementClient(new AgilityManagementOptions
{
    AccessToken = Environment.GetEnvironmentVariable("AGILITY_TOKEN"),
    ApplicationName = "my-sync-job/1.0",   // optional: added to the User-Agent
});
```

The client creates its own `HttpClient` and disposes it with the client. To share a handler or add a proxy, pass your own `HttpClient`. You own that one and dispose it yourself. Its `BaseAddress` is ignored.

```csharp
using var client = new AgilityManagementClient(options, httpClient);
```

### With dependency injection

Register the client with `AddAgilityManagement`, then inject `AgilityManagementClient`:

```csharp
builder.Services.AddAgilityManagement(o =>
{
    o.AccessToken = builder.Configuration["Agility:Token"];
    o.ApplicationName = "my-sync-service/1.0";   // added to the User-Agent
});
```

`AddAgilityManagement` registers the client as a typed `HttpClient` through `IHttpClientFactory`, and returns the `IHttpClientBuilder` so you can add handlers. The client is transient; its credentials are shared.

### Regions

The suffix on the instance GUID selects the API host. You don't need to configure it.

| GUID suffix | Region | Host |
|---|---|---|
| none, `-u` | USA | `https://mgmt.aglty.io` |
| `-us2` | USA 2 | `https://mgmt-usa2.aglty.io` |
| `-c` | Canada | `https://mgmt-ca.aglty.io` |
| `-e` | Europe | `https://mgmt-eu.aglty.io` |
| `-a` | Australia | `https://mgmt-aus.aglty.io` |
| `-d` | Dev | `https://mgmt-dev.aglty.io` |

An unknown suffix throws an `ArgumentException` before any request is sent, rather than sending requests to the wrong region. Server-level calls (`client.ServerUsers`, `client.PersonalAccessTokens`, `client.Types`) and the OAuth sign-in and token calls go to `https://mgmt.aglty.io`. `client.OAuth.GetFetchApiKeyAsync` and `GetPreviewApiKeyAsync` take an instance GUID, so they go to that instance's region. Set `BaseUrl` to override all of this, for example for a local or test deployment of the API.

### Options

| Option | Default | Description |
|---|---|---|
| `AccessToken` | — | A Personal Access Token or an OAuth access token |
| `RefreshToken` | — | An OAuth refresh token; the client gets and renews access tokens from it |
| `RefreshTokenChanged` | — | Called with the new refresh token when the API rotates it |
| `AccessTokenProvider` | — | Supplies a token per request; takes precedence over `AccessToken` and `RefreshToken` |
| `BaseUrl` | from the GUID | Overrides the API host |
| `ApplicationName` | — | Appended to the `User-Agent`, for example `my-job/1.0` |
| `BatchPolling.Interval` | 3 seconds | Time between batch status checks |
| `BatchPolling.Timeout` | 15 minutes | How long to wait for a batch |
| `BatchPolling.NotFoundGracePeriod` | 30 seconds | How long a new batch may return 404 |
| `Retry.MaxRetries` | 3 | Retries for reads; `0` turns them off |
| `Retry.BaseDelay` | 500 ms | First retry delay, doubled each time |
| `Retry.MaxDelay` | 30 seconds | Longest single delay, including `Retry-After` |

## Authentication

The Management API accepts a bearer token: either a **Personal Access Token (PAT)** or an **OAuth access token**.

| | Personal Access Token | OAuth |
|---|---|---|
| Best for | Scripts, CI and server-side jobs | Apps where a person signs in |
| Lifetime | Until the expiry you choose (up to 2 years) | Short; renewed with a refresh token |
| Setup | Create once, store as a secret | Sign-in redirect, then refresh |
| Can manage users and tokens | No | Yes |

### Personal Access Tokens

Create a token once (see [Personal Access Tokens](https://agilitycms.com/docs/developers/personal-access-tokens)), store it as a secret, and pass it as `AccessToken`:

```csharp
using var client = new AgilityManagementClient(new AgilityManagementOptions
{
    AccessToken = Environment.GetEnvironmentVariable("AGILITY_TOKEN"),
});
```

PATs can't call these endpoints. Use OAuth for them:

- Instance user management (`client.InstanceUsers`)
- Token management (`client.PersonalAccessTokens`)

### OAuth

Use OAuth in applications that sign users in.

**Step 1: send the user to sign in.** Send the user's browser to the sign-in URL:

```csharp
var signIn = client.OAuth.GetAuthorizeUri(new Uri("https://myapp.example.com/agility/callback"), state: csrfToken);
```

**Step 2: exchange the code.** Agility redirects back to your URL with `?code=...&state=...`. Check `state`, then exchange the code for tokens:

```csharp
TokenResponseData tokens = await client.OAuth.ExchangeCodeAsync(code);
// tokens.AccessToken, tokens.RefreshToken, tokens.ExpiresIn
```

**Step 3: use the refresh token.** Store the refresh token securely, and give it to the client. The client gets an access token from it and renews it two minutes before it expires:

```csharp
using var client = new AgilityManagementClient(new AgilityManagementOptions
{
    RefreshToken = storedRefreshToken,
    RefreshTokenChanged = newToken => SaveRefreshToken(newToken),   // if the API rotates it
});
```

Steps 1 and 2 need no credentials, so the client you use for sign-in can be created with empty options: `new AgilityManagementClient(new AgilityManagementOptions())`.

Create one client and reuse it: each client built from a `RefreshToken` keeps its own token cache. With `AddAgilityManagement`, every client the container creates shares one, so a rotated refresh token reaches all of them. `RefreshTokenAccessTokenProvider` is the same logic as a standalone `IAccessTokenProvider`, if you'd rather share one provider between clients.

### Your own token provider

Implement `IAccessTokenProvider` to get tokens from anywhere, such as a secrets vault. It's called before every request, so cache the token:

```csharp
public sealed class VaultTokenProvider(ISecretStore vault) : IAccessTokenProvider
{
    public async ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken) =>
        await vault.GetCachedSecretAsync("agility-token", cancellationToken);
}
```

With dependency injection, register it, and `AddAgilityManagement` picks it up when the options don't set one:

```csharp
services.AddSingleton<IAccessTokenProvider, VaultTokenProvider>();
services.AddAgilityManagement(_ => { });
```

### Keeping tokens safe

- Don't commit tokens. Read them from environment variables or a secret store.
- The SDK sends the refresh token in the request body, not the URL, so it stays out of request logs.
- `AgilityManagementException.RequestUri` includes query values. Don't log it if your queries hold anything sensitive.

## Area clients

The client has one property per area of the API.

| Property | What it covers | Guide |
|---|---|---|
| `Content` | Content items: get, list and filter, save, delete, workflow, history, comments | [Content](https://agilitycms.com/docs/dotNet/management-sdk-dotnet-content) |
| `Pages` | Pages, the sitemap, page models and their zones | [Pages](https://agilitycms.com/docs/dotNet/management-sdk-dotnet-pages) |
| `Models` | Content and component models | [Models](https://agilitycms.com/docs/dotNet/management-sdk-dotnet-models) |
| `Containers` | Containers (content lists) | [Containers](https://agilitycms.com/docs/dotNet/management-sdk-dotnet-containers) |
| `Assets` | Uploads, folders and galleries | [Assets](https://agilitycms.com/docs/dotNet/management-sdk-dotnet-assets) |
| `InstanceUsers` | The instance's users and roles | [Instance Users](https://agilitycms.com/docs/dotNet/management-sdk-dotnet-instance-users) |
| `Locales`, `Localization` | Locales; copying and translating into other locales | [Localization](https://agilitycms.com/docs/dotNet/management-sdk-dotnet-localization) |
| `Webhooks` | Webhooks, delivery history and signing secrets | [Webhooks](https://agilitycms.com/docs/dotNet/management-sdk-dotnet-webhooks) |
| `UrlRedirections` | URL redirections, spreadsheet import and export | [URL Redirections](https://agilitycms.com/docs/dotNet/management-sdk-dotnet-url-redirections) |
| `Batches` | The batches behind saves and workflow | [Batches](#batches) below |
| `SyncStatus` | Whether published changes have reached the Fetch API | [Waiting for the Fetch API](#waiting-for-the-fetch-api) below |

Server-level areas don't take an instance GUID:

| Property | What it covers |
|---|---|
| `ServerUsers` | The signed-in user, including the instances a token can reach |
| `PersonalAccessTokens` | Creating, listing, updating and revoking your Personal Access Tokens (needs OAuth) |
| `Types` | The API's enum values (needs no token) |

`OAuth` covers OAuth sign-in and token refresh, which are server-level. Its `GetFetchApiKeyAsync` and `GetPreviewApiKeyAsync` methods take an instance GUID and go to that instance's region.

The method names and argument order line up with the TypeScript Management SDK.

## Argument order

Every instance-level method takes its arguments in the same order: the instance GUID, then the locale (where the route has one), then IDs, then optional settings.

```csharp
var page = await client.Pages.GetPageAsync("1234abcd-u", "en-us", pageId);
```

Methods with many optional settings take an options object: `GetContentListAsync` (`ContentListOptions`), `SavePageAsync` (`SavePageOptions`) and `GetContainerListPagedAsync` (`ContainerListOptions`). The rest take named optional parameters.

## Async and cancellation

Every method that calls the API is asynchronous, ends in `Async`, and takes an optional `CancellationToken` as its last parameter:

```csharp
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
var item = await client.Content.GetContentItemAsync(guid, "en-us", 42, cts.Token);
```

Cancelling throws `OperationCanceledException`. If you cancel while the SDK is waiting for a batch, the batch keeps running on the server.

## Batches

Saves, deletes and workflow operations (publish, unpublish, approve, decline, request approval) don't happen during the HTTP request. The API queues a **batch** and returns its ID. The change happens when the batch is processed, usually within seconds. Reading the item back before then shows the old value.

The SDK waits for you. Every batch-producing method has a `waitForBatch` parameter (default `true`) and returns a `BatchResult`:

```csharp
var result = await client.Content.SaveContentItemAsync(guid, "en-us", item);
int batchId = result.BatchId;                 // the batch
int? itemId = result.ItemId;                  // the saved item's content ID (new items get one here)
IReadOnlyList<int> itemIds = result.ItemIds;  // every item's ID, for multi-item saves
Batch? batch = result.Batch;                  // the processed batch, with its items
```

While waiting, the SDK checks the batch every `BatchPolling.Interval` (3 seconds). A new batch ID can briefly return 404, so a 404 in the first `BatchPolling.NotFoundGracePeriod` (30 seconds) is treated as "not created yet".

| Outcome | What you get |
|---|---|
| Processed, every item succeeded | A `BatchResult` |
| Processed, some items failed | `AgilityBatchException` with the batch, so you can see which items succeeded. The message includes the first few item errors. |
| Aborted (at any point) or deleted | `AgilityBatchException`, as soon as the SDK sees it |
| Not processed within `BatchPolling.Timeout` (15 minutes) | `AgilityBatchTimeoutException` with the batch ID and its last state. The batch keeps running on the server. |
| `cancellationToken` cancelled | `OperationCanceledException`. The batch keeps running. |

To fire and forget, or to wait later, pass `waitForBatch: false`. `result.BatchId` is set; `result.Batch` is `null` and `result.ItemIds` is empty.

```csharp
var queued = await client.Content.PublishContentItemAsync(guid, "en-us", id, waitForBatch: false);
// ... later
var batch = await client.Batches.WaitForBatchAsync(guid, queued.BatchId);
```

`PublishContentItemCascadeAsync` and `PublishPageCascadeAsync` can create several batches and return `BatchCreateResult.BatchIDs`. Wait for each one with `WaitForBatchAsync`.

### Saves land in Staging

Saving a published item moves it back to Staging. The live site keeps serving the previous version until you publish again. Changing one field on a live item takes two batches:

```csharp
var item = await client.Content.GetContentItemAsync(guid, "en-us", id);
item.Fields!["title"] = "New title";
await client.Content.SaveContentItemAsync(guid, "en-us", item);
await client.Content.PublishContentItemAsync(guid, "en-us", id);
```

### Saves replace the whole item

`SaveContentItemAsync` and `SavePageAsync` replace the stored item with what you send. If you send an item with only the field you changed, the other fields are lost. Always read the item, change it, and send the whole thing back. `ContentItem.Fields` is a `JsonObject`, so fields the SDK doesn't know about survive the round trip unchanged.

Page models follow a similar rule for their zones: a zone list you send is the complete list. See [Pages](https://agilitycms.com/docs/dotNet/management-sdk-dotnet-pages) before saving a template.

### Waiting for the Fetch API

A processed publish batch means the Management API has published the item. The Fetch API (what your website reads) syncs shortly after. To wait for that too:

```csharp
await client.SyncStatus.WaitForFetchApiSyncAsync(guid, SyncMode.Fetch);
```

`WaitForFetchApiSyncAsync` throws `TimeoutException` if it gives up.

## Null means omit

The SDK leaves every `null` property out of the request. The API binds a missing property to its default, and:

- It treats a sent list as a replacement. Set a collection only when you mean to send it, and set it to an empty list only when you mean "none".
- It rejects an explicit `null` for many properties, with "The X field is required".

Collections on models are `null` until you set them. Before adding to one on a new object, create it:

```csharp
zone.DefaultModules ??= [];
```

Values inside `ContentItem.Fields` are data, so a field you set to `null` is still sent as `null`.

## Errors

| Exception | When |
|---|---|
| `AgilityManagementException` | Any error from the API or the network. `StatusCode`, `ApiMessage` (the API's own message), `ResponseBody`, `Problem` (for RFC 7807 responses), `RequestId`, `Method` and `RequestUri` say what went wrong. Network failures and timeouts keep the original exception as `InnerException`. |
| `AgilityBatchException` | A batch was processed with failures, aborted or deleted. `BatchId` and `Batch` show what happened. |
| `AgilityBatchTimeoutException` | A batch didn't finish in time. `Waited` says how long the SDK waited. |
| `ArgumentException` | A required argument was missing or invalid, or an instance GUID has an unknown region suffix. |
| `InvalidOperationException` | An authenticated endpoint was called on a client with no credentials, or the options are invalid. |
| `TimeoutException` | `WaitForFetchApiSyncAsync` gave up. |
| `OperationCanceledException` | Your `CancellationToken` was cancelled. Not wrapped. |

`AgilityBatchTimeoutException` is an `AgilityBatchException`, and `AgilityBatchException` is an `AgilityManagementException`, so catch the more specific type first:

```csharp
try
{
    await client.Content.SaveContentItemAsync(guid, "en-us", item);
}
catch (AgilityBatchException ex)
{
    foreach (var failed in ex.Batch?.Items?.Where(i => i.ErrorMessage is not null) ?? [])
        Console.WriteLine($"{failed.ItemID}: {failed.ErrorMessage}");
}
catch (AgilityManagementException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    Console.WriteLine(ex.ApiMessage);
}
```

When you report a problem to Agility support, include `RequestId`.

## Retries

The SDK retries **reads** that fail with 408, 429, 500, 502, 503 or 504, or with a network error or timeout. It waits `Retry.BaseDelay` (500 ms), doubling each time with jitter, up to `Retry.MaxRetries` (3) retries, and honours `Retry-After` up to `Retry.MaxDelay` (30 seconds).

It **never retries a write**, because repeating a save or a publish would repeat the change. That includes the workflow operations the API exposes as `GET` (publish, approve and so on). Content list queries are the one `POST` that is retried, because they only read. Set `Retry.MaxRetries = 0` to turn retrying off.

## View it on GitHub

The SDK is open source: [github.com/agility/agility-cms-management-sdk-dotnet](https://github.com/agility/agility-cms-management-sdk-dotnet). The repository has guides, compiled samples, an API coverage table that maps every Management API operation to its SDK method, and the changelog. Every public type and method has XML documentation, so IntelliSense shows each method's API route and behaviour.
