Skip to main content

Operations in Agility

Operations are named actions defined per asset type that change an asset's lifecycle state or trigger built-in behaviour — closing, deleting, copying, converting. They are distinct from attribute updates: an operation is invoked, not set.

Discovering Operations​

Each type's operations are listed in its Meta API description. Story on a 26.2 instance:

Inactivate, Reactivate, Delete, Undelete, Copy, ShallowCopy, DeepCopy,
QuickClose, QuickSignup, Split, Breakdown, ConvertToDefect, MakeTemplate

The set varies by type — check GET /meta.v1/<AssetType> for yours. Naming varies too: most types close via Inactivate, while Timebox has Close.

Invoking an Operation​

On rest-1.v1, POST to the asset with ?op= and an empty body:

curl -X POST -H "Authorization: Bearer ${AGILITY_ACCESS_TOKEN}" \
"${AGILITY_BASE_URL}/rest-1.v1/Data/Story/3884?op=Inactivate"

The response is the asset, including its OID with the new moment (Story:3884:6277).

On api/asset, use an execute command — which can run one operation across many assets in a single request.

The Lifecycle Quartet​

The four operations nearly every type has, and what they actually do:

OperationEffectAfter it runs
InactivateCloses the assetAssetState: Closed, IsDeleted: False
ReactivateReopens a closed assetAssetState: Active
DeleteSoft-deletes the assetHidden from queries unless you ask for deleted assets; recoverable until purged
UndeleteRestores a deleted assetBack to its prior state
Inactivate is not Delete

A persistent confusion, worth stating plainly: Inactivate closes; it does not touch IsDeleted. Closed assets remain fully queryable. Delete is the soft delete — and even deleted assets are still stored and recoverable until an administrator purges them.

Two interactions between the pairs:

  • A Closed asset cannot be deleted — Delete on it fails with Violation'AssetCannotDeleteClosed. Reactivate first, then Delete.
  • A Closed asset accepts only backfill — writing an attribute that had no value succeeds without reopening (by design, so missing values can be pushed after close), but changing an existing value fails with Violation'ClosedAssetCannotEditExistingValues.

The whole lifecycle in one picture:

Undelete has a quirk when invoked through the Bulk API: the update/execute command must include IsDeleted: true to find its target — see updating assets.

The Copy Family​

Copy, ShallowCopy and DeepCopy create new assets from an existing one. On rest-1.v1 the response hands you the new asset:

POST /rest-1.v1/Data/Story/3884?op=Copy
→ <Asset id="Story:3885:6280" …> ← the copy's OID

If your integration needs the copy's OID, invoke the operation through rest-1.v1 — the Bulk API's execute command does not return the OID of an asset an operation generates.

The Convert Family​

ConvertToDefect (on Story) and ConvertToStory (on Defect) migrate a work item across types. The operation creates a new asset of the destination type: attribute values the two types share are copied, child tasks and tests move, effort records move, and attachments and links are copied. The source asset is then renamed <Name> (Converted To D-01234) and soft-deleted. The conversion is transactional — if any step fails, nothing changes.

Required attributes are enforced when the new asset is saved. An attribute that is required on the destination type but ends up with no value — typically one that exists only on the destination type, such as a required custom field — fails the whole conversion with Violation'Required'AttributeDefinition'<name>'<Type>, and the source work item is left untouched.

The conversion operation itself accepts argument values for destination attributes — that is how the product UI satisfies destination-only required fields when it converts. API delivery of those arguments is limited: the body of a rest-1.v1 ?op= POST is ignored, and through the Bulk API's execute command args map, only text-type argument values (Text, LongText) reach the conversion — keys are full attribute tokens on the destination type, e.g. "Defect.FoundBy". Numeric, relation, and multivalue argument values fail, and the conversion fails with them. For a required destination field that isn't text: populate it on the source first if the types share it, and if it exists only on the destination type, complete the conversion in the UI.

As with the copy family, the Bulk API's execute command does not return the new asset's OID — rest-1.v1 does. To locate the asset a Bulk API conversion created, query the destination type: attribute values, including Name, are copied to it.

Status Changes Are Not Operations​

Moving a work item through its workflow — Future → In Progress → Done — is an ordinary attribute update to Status, which is a relation to a list value. Discover the values by querying the list type:

{
"from": "StoryStatus",
"select": ["Name", "Order", "RollupState"],
"sort": ["Order"],
"page": { "size": 50, "start": 0 }
}

(Status list values carry Name, Order, Description and RollupState — there is no IsClosed flag. RollupState is what reporting uses to treat a status as effectively closed; see reproducing reported numbers.)

Then set the status like any relation, on either write surface:

<!-- rest-1.v1, XML -->
<Asset>
<Relation name="Status" act="set"><Asset idref="StoryStatus:134"/></Relation>
</Asset>
// rest-1.v1 also accepts JSON, with an Attributes wrapper — verified live
{ "Attributes": { "Status": { "value": "StoryStatus:134", "act": "set" } } }
// api/asset update command
{ "from": "Story:3884", "update": { "Status": "StoryStatus:134" } }

The API does not walk a transition graph: Status can be set directly to any active list value. Board-style transition rules are a UI concern. Use OIDs for the values — status names are localized and instance-editable.

History of Operations​

Every operation writes a new moment. To see who did what and when, read the asset's history — see Moments and history and the /rest-1.v1/Hist namespace.

Permissions​

Whether you may invoke an operation is governed by your member's role in the asset's scope — see Roles and permissions. A denied operation fails; handle it like any other authorization error.

Code Example​

class OperationClient {
constructor(
private baseUrl: string,
private headers: HeadersInit
) {}

/** Invoke a lifecycle operation. Returns the response asset's OID. */
async execute(oid: string, operation: string): Promise<string> {
const [type, id] = oid.split(':');
const response = await fetch(
`${this.baseUrl}/rest-1.v1/Data/${type}/${id}?op=${operation}`,
{ method: 'POST', headers: this.headers }
);
if (!response.ok)
throw new Error(`${operation} failed: ${response.status}`);
const text = await response.text();
return /id="([^"]+)"/.exec(text)?.[1] ?? '';
}
}

// Close a story; copy one and get the copy's OID
const client = new OperationClient(baseUrl, headers);
await client.execute('Story:3884', 'Inactivate');
const copyOid = await client.execute('Story:3884', 'Copy');