Create AzDO Work Item (Markdown-aware)
az boards work-item create stores --description as HTML with no flag to override. To get a Markdown-rendered description, create the item via az rest POST with a JSON-Patch body that includes /multilineFieldsFormat/System.Description = "Markdown" alongside the field op. The format cannot be flipped reliably after creation. To convert an existing HTML item, delete and recreate.
Step 1: Resolve org and project (always first)
Shell variables do not survive between Bash calls, so resolve these once, read the printed
values, and paste the literals into every later command. Never carry $BASE across calls.
# cut -f2- + sed, not `tr -d ' '`. AzDO project names may contain spaces.
TRIM="s/^ *//; s/ *$//"
ORG_URL="${AZDO_ORG_URL:-$(az devops configure --list | grep '^organization' | cut -d= -f2- | sed "$TRIM")}"
PROJECT="${AZDO_PROJECT:-$(az devops configure --list | grep '^project' | cut -d= -f2- | sed "$TRIM")}"
: "${ORG_URL:?no organization, set AZDO_ORG_URL or run az devops configure --defaults}"
: "${PROJECT:?no project, set AZDO_PROJECT or run az devops configure --defaults}"
echo "ORG_URL=$ORG_URL"
echo "BASE=$ORG_URL/$PROJECT/_apis/wit"
If this command aborts with either :? message, stop and ask the user for their AzDO
organization URL and project. They are team-specific. Never guess them, never fall back to
an org name seen elsewhere in the repo or conversation. How the user persists the values (env
var, az devops configure --defaults) is their call.
The two constants below are fixed and can be typed literally. They need no resolution step:
| Constant | Value | Why |
| ----------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| resource id | 499b84ac-1321-427f-aa17-267ca6975798 | AzDO's fixed Azure AD app id, same for every org. Without --resource, az mints an ARM token and the call fails with TF400813. |
| api version | 7.1 | |
Step 2: Work item types
Types come from the project's process template. List them before creating if unsure. Substitute
the real BASE printed by step 1. The placeholder is not a shell variable.
az rest --resource 499b84ac-1321-427f-aa17-267ca6975798 \
--uri "<BASE>/workitemtypes?api-version=7.1" \
--query "value[].name" -o tsv
Casing matters and is often surprising (e.g. Technical task with a lowercase t). URL-encode spaces as %20 after the leading $: …/workitems/$Technical%20task.
Step 3: Show the draft and get approval (never skip)
Creating a work item is outward-facing: it lands on a shared board, notifies watchers, and appears in queries and reports. Soft-delete recovers the item but does not un-send any of that. The description is also your prose, not the user's. They have not seen the words yet.
Print the full draft and wait for an explicit go-ahead before the POST in step 4:
Type: Technical task
Title: <TITLE>
Parent: #<PARENT_ID>, <parent title, fetched so a wrong id is visible>
Area: <AREA>
Iteration: <ITERATION>
Tags: <TAGS>
Description:
<THE FULL MARKDOWN BODY, verbatim, not a summary of it>
Fetch the parent's title rather than echoing the id back. A transposed id is invisible as a number and obvious as a title. This costs one call:
az boards work-item show --id <PARENT_ID> --query "fields.\"System.Title\"" -o tsv
Rules:
- Print the description in full. Summarizing it defeats the point of the review.
- "Create a ticket for X" is a request to draft one, not standing approval to file it. Ask anyway.
- Approval covers the draft as shown. If the user amends anything, show the corrected draft again.
- Approval for one ticket is not approval for the next. Filing several means confirming each, or showing the whole set and getting one go-ahead that explicitly covers all of them.
- Skip this step only if the user has said, in this session, to file without review.
Step 4: Create call (template)
Substitute the real BASE and ORG_URL printed by step 1. The placeholders are not shell
variables. Note the \$ before the type name: that dollar sign is part of the AzDO URL syntax
and must survive shell quoting.
cat > /tmp/wi-create.json <<'EOF'
[
{"op":"add","path":"/fields/System.Title","value":"<TITLE>"},
{"op":"add","path":"/fields/System.AreaPath","value":"<AREA>"},
{"op":"add","path":"/fields/System.IterationPath","value":"<ITERATION>"},
{"op":"add","path":"/fields/System.Tags","value":"<TAGS>"},
{"op":"add","path":"/fields/System.Description","value":"<MARKDOWN BODY. Use \\n for newlines. Real backticks/asterisks OK>"},
{"op":"add","path":"/multilineFieldsFormat/System.Description","value":"Markdown"},
{"op":"add","path":"/relations/-","value":{"rel":"System.LinkTypes.Hierarchy-Reverse","url":"<ORG_URL>/_apis/wit/workItems/<PARENT_ID>"}}
]
EOF
az rest --method POST --resource 499b84ac-1321-427f-aa17-267ca6975798 \
--url "<BASE>/workitems/\$Technical%20task?api-version=7.1" \
--headers "Content-Type=application/json-patch+json" \
--body @/tmp/wi-create.json \
--query "{id:id, type:fields.\"System.WorkItemType\", descFormat:multilineFieldsFormat, tags:fields.\"System.Tags\", parent:relations[?attributes.name=='Parent'].url|[0]}" -o json
trash-put /tmp/wi-create.json
Drop the ops you don't need. Area and iteration default to the project root. Tags and the parent relation are optional.
Verify the response shows "descFormat": {"System.Description": "markdown"} (lowercase). If it shows "html", the format op was missed. See below.
Update an existing item's description (Markdown-preserving)
This overwrites the existing description. There is no merge and no undo. Read the current value first, show the user what is being replaced and what replaces it, and get approval:
az boards work-item show --id <ID> --query "fields.\"System.Description\"" -o tsv
cat > /tmp/wi-patch.json <<'EOF'
[
{"op":"add","path":"/fields/System.Description","value":"<MARKDOWN BODY>"},
{"op":"add","path":"/multilineFieldsFormat/System.Description","value":"Markdown"}
]
EOF
az rest --method PATCH --resource 499b84ac-1321-427f-aa17-267ca6975798 \
--uri "<ORG_URL>/_apis/wit/workitems/<ID>?api-version=7.1" \
--headers "Content-Type=application/json-patch+json" \
--body @/tmp/wi-patch.json \
--query "{descFormat:multilineFieldsFormat, desc:fields.\"System.Description\"}" -o json
trash-put /tmp/wi-patch.json
Caveat: if the item was originally created as HTML, this PATCH may keep descFormat: html even though the Markdown characters survive in storage. The reliable fix is delete + recreate via the POST above.
Add a parent link to an existing item (without recreating)
az boards work-item relation add \
--id <CHILD_ID> --relation-type parent --target-id <PARENT_ID> \
--query "{id:id, parent:relations[?attributes.name=='Parent'].url|[0]}" -o json
Soft-delete (recoverable from recycle bin)
Confirm the id and title with the user first. --yes suppresses the CLI's own prompt, so this
runs unattended. Deleting the wrong item is silent until someone misses it.
az boards work-item delete --id <ID> --yes
--destroy removes permanently, bypassing the recycle bin. Never pass it unless the user asked
for permanent destruction in those terms.
Writing the title and description
Title names a verifiable thing, not a region: Checkout stalls on a zero-price item, not
Checkout. No [Bug] prefix or id echo.
Two rules for the body. They are where drafts go wrong.
No em-dashes. Split the sentence in two. Same for semicolons and colons that join clauses.
Also no ≥, → or other symbols. Write "at most 100 records".
Every sentence earns its place. Cut the ones the reader could skip without acting differently: scaffolding labels ("Background:", "Why it works:"), the parent id, a restated title, hedging, step lists nobody asked for. Open with what is wrong or what changes, then stop.
There is no target length. One line is right when one line is the whole fact, and a gnarly race condition may need three paragraphs of repro detail. Length has to come from facts the reader acts on. Long drafts are usually long from throat-clearing, not from content, so check which one you have before cutting.
Name the actor while you are at it. "The handler retries twice", not "requests are retried".
Nightly imports silently drop every order past the first 100. The vendor paginated
/ordersin March. The importer still reads a single unpaginated response.
Troubleshooting
| Symptom | Cause | Fix |
| ------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------- |
| URL contains //_apis or an empty segment | $BASE carried over from an earlier Bash call and expanded to empty | Re-run step 1, paste the literal value |
| TF400813: not authorized, empty user GUID | token had no AzDO scope | pass --resource 499b84ac-…. If it persists, az logout && az login |
| descFormat comes back html | the /multilineFieldsFormat/System.Description op was missing | delete and recreate, see the caveat above |
| type not found | wrong casing or unencoded space | re-list types (step 2), encode spaces as %20 |
Related skills
dev-azdo:ticket: transition a work item between states (active / cr / ready / closed). Use after creating.dev-azdo:ticket-comments: post a Markdown comment on the discussion thread.dev-azdo:feature-branch: start a feature branch from an existing ticket id.dev-azdo:pr: create / checkout / list / complete pull requests linked to a ticket.
Self-test after creating
- Response JSON shows expected
id,type,tags,parent,descFormat: markdown. - Open
<ORG_URL>/<PROJECT>/_workitems/edit/<id>in a browser if visual confirmation is needed. - If embedding the new id into a plan or TODO, update those references in the same turn.