One Checkbox, Two Apps
The feature sounded tiny in the kickoff meeting. Our video editor gets a publish screen, and on that screen there is a checkbox: also publish to the social video app that another team in the company runs. The user edits once, taps publish once, and the video appears in both places.
Behind the checkbox: two apps with different bundle identifiers, different codebases, different roadmaps, different release trains, and an export file that is routinely several hundred megabytes. iOS keeps those two apps in separate sandboxes, and the org chart keeps those two teams in separate rooms. The interesting engineering was never the video processing. It was designing what, exactly, crosses the boundary.
That project permanently changed how I think about working with other teams, because the technical question and the organizational question turned out to be the same question. Two sandboxed processes that want to cooperate cannot reach into each other's memory. Neither can two teams. Everything depends on what you agree to pass across.
Two Ways to Hand Over a Video
iOS offers two fundamentally different transports for getting a video from app A to app B, and the technical evaluation for the checkbox came down to choosing between them.
Option one: pass the file
The first option is the one every iOS developer reaches for, because the system hands it to you. The editor exports to its own sandbox:
let exportURL = FileManager.default
.temporaryDirectory
.appendingPathComponent("export.mp4")
and offers it outward through the share sheet:
let controller = UIActivityViewController(
activityItems: [exportURL],
applicationActivities: nil
)
present(controller, animated: true)
On the receiving side, app B ships a share extension. The system wraps the file in an NSItemProvider, the extension loads a representation, and copies it somewhere it can keep:
itemProvider.loadFileRepresentation(
forTypeIdentifier: UTType.movie.identifier
) { url, error in
// the URL is temporary; copy the file
// into our own container before it disappears
}
The extension then typically moves the copy into an app group container so the main app can pick it up, because the extension and the main app are themselves two sandboxed processes.
This path is the universal one. It works between apps from different companies, it needs no photo library permission, and the receiving app is in full control of what it accepted. It is exactly how sharing into an arbitrary third-party app should work.
But look at what happens to the bytes. The video exists in the editor's sandbox. The extension materializes a representation and copies it. The main app receives it through the app group. For an 800 MB export, the feature would multiply the user's storage cost and add a copy step measured in seconds, purely as an artifact of the transport. And the user experience is a share sheet detour: the system asks the user where the video should go, on a screen where the product has already answered that question with a checkbox.
Option two: pass the identifier
The second option changes what crosses the boundary. Instead of handing over the bytes, the editor saves the export into the system photo library, once:
var placeholderIdentifier: String?
PHPhotoLibrary.shared().performChanges({
let request = PHAssetChangeRequest
.creationRequestForAssetFromVideo(atFileURL: exportURL)
placeholderIdentifier = request
.placeholderForCreatedAsset?.localIdentifier
})
What the other app receives is not a file. It is the asset's localIdentifier, a string of a few dozen bytes that names the asset inside the photo library:
69D53A4B-8F24-4D7A-.../L0/001
App B dereferences it through PhotoKit:
let assets = PHAsset.fetchAssets(
withLocalIdentifiers: [identifier],
options: nil
)
guard let asset = assets.firstObject else { return }
PHImageManager.default().requestAVAsset(
forVideo: asset,
options: nil
) { avAsset, _, _ in
// feed the publish pipeline
}
The photo library plays the role of shared memory. Both apps hold a reference into the same heap; the thing that travels between them is a pointer.
This is not an exotic design. It is exactly how the public share kits of the large short-video platforms work: the pattern behind an editor like CapCut landing a finished cut directly in TikTok's composer. Their public SDKs document the same shape, save the media to the photo library first, then pass an array of local identifiers; the SDK's own description of the mechanism is that the album is the cross-process channel for the resource.
What Actually Crossed the Boundary: a Protocol
Once the transport question was settled, the real collaboration artifact emerged, and it was not a video pipeline. The two teams co-designed a small SDK, and the SDK is best understood as a protocol with five responsibilities.
Validation at the boundary. The request builder checks everything it can before leaving home: the asset exists, it is a video, the duration and count are inside what app B accepts, app B is installed, and app B's version speaks this protocol version. Every constraint that can fail fast on the sender's side is one less failure the receiver has to explain.
A payload measured in kilobytes. The serialized request is small enough to read in one glance:
{
"protocol_version": 3,
"media_type": "video",
"local_identifiers": ["69D53A4B-.../L0/001"],
"state": "7F92E1C4",
"title": "…",
"sync_publish": true,
"callback_scheme": "editor://share/callback"
}
The media travels as a reference. The business context travels as metadata. Nothing in the payload is large, and nothing in it is a copy of something the other side could fetch for itself.
Routing, not transferring. send() ultimately constructs a URL and asks the system to open app B. The SDK's job at that moment is addressing and versioning, not data transfer. The heavy asset never touches this channel.
A dispatcher that trusts nothing. On arrival, app B validates the protocol version, re-fetches the asset by identifier, and re-checks the constraints itself. The sender's validation is a courtesy; the receiver's validation is the contract. If the asset is missing, or Photos permission is absent, the failure is B's to detect and report, because only B knows what it needs.
State and callback. Publishing is asynchronous and user-interruptible. The request carries an opaque state token; whenever app B finishes, cancels, or fails, it calls back through the editor's URL scheme with that token, and the SDK correlates the response to the pending request. Neither app ever blocks on the other.
request.send { response in
switch response.result {
case .success: markSyncPublished()
case .cancelled: revertCheckbox()
case .failure(let error): surface(error)
}
}
The Tradeoff We Argued About
The evaluation was not one-sided, and writing down what each transport costs is the part I would repeat on any project.
| Pass the file | Pass the identifier | |
|---|---|---|
| What crosses | the bytes, again | a 64-byte reference |
| Storage cost | 2 to 3 copies of the export | one asset in Photos |
| Photos permission | not needed | required on both sides |
| UX | share sheet detour | one tap, straight to B's composer |
| Reach | any app, any company | only apps that adopt the SDK |
| Failure modes | copy fails, extension memory limits | asset deleted, permission denied |
The requirement that decided it was stated in one line during the technical review: the sync feature must not create a second copy of the export. Users who publish daily would pay that copy in storage and time on every publish, for a video they already have. Passing the identifier makes the marginal cost of the second destination approximately zero, and a third destination would cost the same nothing. One export, one asset, any number of consumers.
The costs we accepted were real. App B needs photo library permission, and iOS 14 had just complicated that story with the limited library, where the user can grant access to a subset of photos and the freshly saved asset may not be in it. The asset also has a lifetime we do not control; the user can delete it from Photos between the handoff and the fetch. Both of those became explicit failure cases in the dispatcher rather than surprises, which is the best you can do when your shared memory is owned by the system and the user.
The Metaphor Earned Its Title
I said the technical question and the organizational question were the same. Here is the mapping, and none of it is a stretch.
The sandbox is the team boundary. App B cannot read app A's container, and team B cannot read team A's codebase, backlog, or assumptions. Any plan that begins with "they can just look at how we do it" is dereferencing a pointer into an address space you do not own.
Copying files is copying context. The file-passing transport has an organizational twin: the forty-page handoff document, the duplicated utility module, the "here is a snapshot of our data, load it into your system." Every copy starts aging the moment it is made, and reconciling drifted copies is the organizational version of paying storage for the same video three times.
A good interface is a small identifier plus a shared source of truth. The whole design worked because both apps could dereference the same sixty-four bytes against the same system service. Teams that collaborate well do the same thing: they pass ticket IDs into a shared tracker, schema names into a shared registry, document links into a shared wiki, and each side fetches the freshest version when it actually needs it. The reference stays small and stable; the referent stays authoritative and current.
Version negotiation is roadmap negotiation. The protocol_version field exists because the two apps ship on different schedules, and neither can force the other to upgrade in lockstep. That is every pair of teams. A contract that assumes both sides deploy together is a contract that breaks on the first uncoordinated release.
Callbacks with state are how you survive asynchrony. We never blocked the editor waiting for the social app, and the correlation token meant a response arriving a minute later still found its request. Cross-team requests deserve the same design: a clear request, an ID to correlate by, and no meeting held hostage waiting for a synchronous answer.
Validation on both sides is not distrust; it is ownership. The sender validates so that most failures never leave home. The receiver validates because only it can defend its own invariants. In collaboration terms: the requesting team does its homework, and the receiving team still owns its acceptance criteria. Skipping either half is how integrations rot.
Takeaway
The checkbox shipped, and the video crossed the boundary as sixty-four bytes.
The design lesson is compact: when two sandboxed parties need to share something heavy, the winning move is usually not a better pipe for the bytes. It is finding the shared source of truth both sides can already reach, and passing the smallest reference that lets the other side fetch exactly what it needs, when it needs it, with a version field for the day the contract evolves and a state token for the day the answer comes late.
That sentence describes PhotoKit and a URL scheme. It also describes every healthy working relationship between two engineering teams I have seen since. Cross-team collaboration is cross-process communication; the teams that struggle are usually copying files at each other, and the teams that scale are passing identifiers.