Showing the uploader their Box file progress with an @mention comment — start, done, and failure
A Box folder pipeline comments on each uploaded file at start, completion and failure, mentioning the uploader from the file modified_by field.

On this page
Introduction
I built a pipeline that starts whenever someone uploads a file into a Box folder, and processes that file. But every time I dropped a file in, I could not tell whether it was being processed. Whether a job succeeded or failed, the uploader got no notification and could only come back later to check whether the file was done.
I wanted the pipeline to report its progress in real time, so the uploader could see how far their file had got. I did not want to build a separate UI, and I did not want to post to Slack either, because the number of notifications grows with the number of users.
The answer was a comment on the uploaded file that @mentions the uploader, posted when processing starts, when it finishes with a link to the result, and when it fails for good. Box turns the mention into an email and an inbox notification, so the progress reaches the person waiting on it without them opening Box.
Which Box signals reach the uploader
I listed the ways the uploader could see progress without building a separate UI:
| Signal | How the uploader finds out | Side effect |
|---|---|---|
| Lock the file | A lock icon next to the file name, no notification | Reads as “you cannot edit this”, and a job that crashes leaves the lock behind |
| Write to the description | Shown in the details panel, no notification | Overwrites a field people write in themselves |
Rename to [processing] … | The name changes in the file list, no notification | Fires FILE.RENAMED, so the Box webhook that starts the pipeline runs again |
| Box Skills status card | Shown in the preview sidebar, no notification | Only visible with the file preview open |
| Comment with @mention | Shown in the comment panel, with an email and a Box inbox notification | Collaborators with comment emails on also get one |
A comment on the file, mentioning whoever uploaded it
The pipeline has two components:
- Box webhook: puts one message on a queue for each upload.
- Worker: takes messages off the queue, processes the file, and writes the output file back to Box.
The worker reports progress in three comments on the file. It posts them with Box’s POST /comments endpoint. The mention goes in tagged_message, the field Box scans for @[user_id:name] and turns into a notification.
- Start, once the worker holds the file’s lock, knows the format is supported, and knows this version is not one it already finished.
- Done, after the output file is uploaded, with a link to it.
- Failed, only when the last delivery fails too. The queue delivers a message at most three times, and an earlier failure can still succeed on the next delivery. The worker therefore comments on the file only once the last delivery has also failed.

The comment is posted by post_comment() in the connector that wraps the Box API, and it is only a few lines. mention is required, because a comment with nobody to notify is not a progress report:
_MENTION_NAME_DELIMITERS = str.maketrans("", "", "[]:")
def post_comment(self, file_id: str, text: str, *, mention: Person) -> None: item = CreateCommentItem(id=file_id, type=CreateCommentItemTypeField.FILE) name = mention.name.translate(_MENTION_NAME_DELIMITERS).strip() or mention.id tagged = f"@[{mention.id}:{name}] {text}" self._call(lambda: self._client.comments.create_comment(None, item, tagged_message=tagged))The display name loses [, ] and : because they are the mention syntax’s own delimiters, and Box publishes no escape for them. Take a display name of Sato [QA]: Tokyo:
@[12345:Sato [QA]: Tokyo]@[12345:Sato QA Tokyo]In the string before stripping, the mention syntax ends at the ] after QA. The name only labels the mention, and Box routes the notification by user_id, so who receives it does not change. The None in the last line is Pitfall 1 below.
A comment that fails to post never fails the job. An escaping error would send the message back to the queue, and a failed completion comment would reprocess a file whose output was already uploaded. So a failure is only logged as a warning. The warning carries only the exception type, never the uploader’s name or the comment text.
Who to mention: modified_by, not created_by
The person waiting is the file’s modified_by, the user who uploaded its current version. The worker already calls GET /files/{id} for the file name and parent folder, so the uploader costs one more entry in fields and no extra request:
file = self._call(lambda: self._client.files.get_file_by_id( file_id, fields=["name", "parent", "size", "version_number", "modified_by"]))I checked which field follows the uploader by uploading versions from two accounts. With the first version mine, both fields named me. After the app’s service account uploaded a new version, modified_by named the service account and created_by still named me.
When I uploaded again, modified_by came back to me. created_by records whoever put the file there first, so it mentions the wrong person once a colleague replaces the file.
A version the app’s own account uploaded needs no comment. So the worker skips the comment when modified_by matches the account from GET /users/me, which is the app’s own account.
The mention still arrives with comment emails turned off
A mention reaches the uploader even after they switch off comment emails for the folder. I unchecked “Adds a comment” in the folder’s notification settings, which turns off the email for each comment. When the app then posted a mention, the email and the Box inbox notification both still arrived.


So the comments do not have to flood a shared folder. A collaborator who does not want the progress of other people’s files turns off comment emails on that folder, and still hears about their own uploads through the mention.
Pitfall 1: Box returns 400 when message and tagged_message are both sent
create_comment() in the Box Python SDK sends message and tagged_message together, and Box rejects that pair. The SDK, box_sdk_gen, makes message a required positional argument and builds the request body with both keys:
request_body: Dict = { 'message': message, 'tagged_message': tagged_message, 'item': item,}Passing the comment text as message and the same text with the mention added as tagged_message reads like what the signature asks for. But against real Box, every call came back:
Message: 400 Bad Request; Request ID: … Status code: 400 Code: bad_requestBox’s own guide already says to pass a string with a mention “as the tagged_message instead of the message”. The SDK still requires message, so I passed None. The SDK’s serialize() drops keys whose value is None, so the request body that goes out carries tagged_message alone:
# SDK argumentscreate_comment(text, item, tagged_message=tagged)
# Request body sent{ "message": "Processing has started.", "tagged_message": "@[12345:Sato] Processing has started.", "item": { "id": "…", "type": "file" }}# SDK argumentscreate_comment(None, item, tagged_message=tagged)
# Request body sent{ "tagged_message": "@[12345:Sato] Processing has started.", "item": { "id": "…", "type": "file" }}Pitfall 2: announcing the start only on the first delivery skips it after lock contention
Gating the start comment on the first delivery drops it whenever that delivery cannot take the file’s lock. The worker holds a lock per file in a DynamoDB job record, so two jobs never process the same file at once, and a job that finds the lock taken goes back to the queue for a later delivery.
Box can send FILE.MOVED and FILE.UPLOADED for the same file at the same moment, so losing the lock is an ordinary path. The queue counts deliveries in ApproximateReceiveCount, and a gate that announces only when the count is 1 fails on it.
Delivery 1 loses the lock and announces nothing. Delivery 2 takes the lock and processes the file, but its count is 2, so it announces nothing either. The uploader gets the completion comment with no start before it.
A rerun breaks the same gate. When a new version arrives while the worker is busy, the worker processes again inside the same delivery, and that newer version is never announced if the count is already 2 or more.
The fix is to record which version of the file was announced, on the DynamoDB record that already holds the file’s lock, in a field named start_notified.
The worker writes that field with a conditional update, which succeeds only when start_notified is not already the current version. So only one write per version succeeds, and the start comment goes out once:
def claim_start_notice(self, key: str, *, source_version: str | None) -> bool: try: self._table.update_item( Key={"pk": key}, UpdateExpression="SET start_notified = :ver", ConditionExpression=( "attribute_not_exists(start_notified) OR start_notified <> :ver" ), ExpressionAttributeValues={":ver": source_version or ""}, ) except ClientError as exc: if exc.response["Error"]["Code"] != "ConditionalCheckFailedException": raise return False return TrueCompleting a job removes start_notified, and a failure leaves it in place, so:
- Lock refused on delivery 1.
start_notifiedwas never written, so delivery 2 announces the start. - Retry after a failure.
start_notifiedis already this version, so the retry stays silent. - Rerun for a newer version. Completion cleared
start_notified, so the rerun announces. - New version after a final failure.
start_notifiedstill holds the old version, so the new one is announced.
Summary
Box now tells the uploader that processing started, that it finished with a link to the result, or that it failed. The file’s comment panel keeps that history. I relied entirely on Box’s own mechanisms: the worker now posts three kinds of comment, one per processing state, and @mentions the uploader in each.
What the work taught me was mostly about Box. A mention is delivered separately from comment emails, so collaborators can mute a busy folder without missing their own files. modified_by names the uploader of the current version, where created_by keeps naming the first one.
Passing None as message to the SDK’s create_comment() sends tagged_message alone, which Box accepts. Recording the start per file version in start_notified announces each version once, whichever delivery gets the lock.
