What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
Get started with the Agility CMS .NET Management SDK 2.0: install it, create a client, authenticate, and learn how batches, errors and retries work.
This page has moved — the Management SDK is now documented elsewhere. The JavaScript and .NET guides have been merged into a single set with per-language code tabs. Read the current guide.
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, and the package is Agility.Management.SDK 1.0.12-beta on NuGet. 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.
Add the package from NuGet:
dotnet add package Agility.Management.SDK
The source is on GitHub.
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
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.
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.
using var client = new AgilityManagementClient(options, httpClient);
Register the client with AddAgilityManagement, then inject AgilityManagementClient:
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.
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.
| 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 |
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 |
Create a token once (see Personal Access Tokens), store it as a secret, and pass it as AccessToken:
using var client = new AgilityManagementClient(new AgilityManagementOptions
{
AccessToken = Environment.GetEnvironmentVariable("AGILITY_TOKEN"),
});
PATs can't call these endpoints. Use OAuth for them:
client.InstanceUsers)client.PersonalAccessTokens)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:
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:
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:
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.
Implement IAccessTokenProvider to get tokens from anywhere, such as a secrets vault. It's called before every request, so cache the token:
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:
services.AddSingleton<IAccessTokenProvider, VaultTokenProvider>();
services.AddAgilityManagement(_ => { });
AgilityManagementException.RequestUri includes query values. Don't log it if your queries hold anything sensitive.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 |
Pages | Pages, the sitemap, page models and their zones | Pages |
Models | Content and component models | Models |
Containers | Containers (content lists) | Containers |
Assets | Uploads, folders and galleries | Assets |
InstanceUsers | The instance's users and roles | Instance Users |
Locales, Localization | Locales; copying and translating into other locales | Localization |
Webhooks | Webhooks, delivery history and signing secrets | Webhooks |
UrlRedirections | URL redirections, spreadsheet import and export | URL Redirections |
Batches | The batches behind saves and workflow | Batches below |
SyncStatus | Whether published changes have reached 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.
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.
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.
Every method that calls the API is asynchronous, ends in Async, and takes an optional CancellationToken as its last parameter:
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.
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:
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.
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.
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:
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);
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 before saving a template.
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:
await client.SyncStatus.WaitForFetchApiSyncAsync(guid, SyncMode.Fetch);
WaitForFetchApiSyncAsync throws TimeoutException if it gives up.
The SDK leaves every null property out of the request. The API binds a missing property to its default, and:
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:
zone.DefaultModules ??= [];
Values inside ContentItem.Fields are data, so a field you set to null is still sent as null.
| 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:
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.
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.
The SDK is open source: 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.