diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -236,6 +236,8 @@
     'PhutilICSParser' => 'parser/calendar/ics/PhutilICSParser.php',
     'PhutilICSParserException' => 'parser/calendar/ics/PhutilICSParserException.php',
     'PhutilICSParserTestCase' => 'parser/calendar/ics/__tests__/PhutilICSParserTestCase.php',
+    'PhutilICSWriter' => 'parser/calendar/ics/PhutilICSWriter.php',
+    'PhutilICSWriterTestCase' => 'parser/calendar/ics/__tests__/PhutilICSWriterTestCase.php',
     'PhutilINIParserException' => 'parser/exception/PhutilINIParserException.php',
     'PhutilIPAddress' => 'ip/PhutilIPAddress.php',
     'PhutilIPAddressTestCase' => 'ip/__tests__/PhutilIPAddressTestCase.php',
@@ -823,6 +825,8 @@
     'PhutilICSParser' => 'Phobject',
     'PhutilICSParserException' => 'Exception',
     'PhutilICSParserTestCase' => 'PhutilTestCase',
+    'PhutilICSWriter' => 'Phobject',
+    'PhutilICSWriterTestCase' => 'PhutilTestCase',
     'PhutilINIParserException' => 'Exception',
     'PhutilIPAddress' => 'Phobject',
     'PhutilIPAddressTestCase' => 'PhutilTestCase',
diff --git a/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php b/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php
--- a/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php
+++ b/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php
@@ -10,7 +10,51 @@
   private $minute = 0;
   private $second = 0;
   private $timezone;
-  private $viewerTimezone;
+
+  public static function newFromISO8601($value, $timezone = 'UTC') {
+    $pattern =
+      '/^'.
+      '(?P<y>\d{4})(?P<m>\d{2})(?P<d>\d{2})'.
+      '(?:'.
+        'T(?P<h>\d{2})(?P<i>\d{2})(?P<s>\d{2})(?<z>Z)?'.
+      ')?'.
+      '\z/';
+
+    $matches = null;
+    $ok = preg_match($pattern, $value, $matches);
+    if (!$ok) {
+      throw new Exception(
+        pht(
+          'Expected ISO8601 datetime in the format "19990105T112233Z", '.
+          'found "%s".',
+          $value));
+    }
+
+    if (isset($matches['z'])) {
+      if ($timezone != 'UTC') {
+        throw new Exception(
+          pht(
+            'ISO8601 date ends in "Z" indicating UTC, but a timezone other '.
+            'than UTC ("%s") was specified.',
+            $timezone));
+      }
+    }
+
+    $datetime = id(new self())
+      ->setYear((int)$matches['y'])
+      ->setMonth((int)$matches['m'])
+      ->setDay((int)$matches['d'])
+      ->setTimezone($timezone);
+
+    if (isset($matches['h'])) {
+      $datetime
+        ->setHour((int)$matches['h'])
+        ->setMinute((int)$matches['i'])
+        ->setSecond((int)$matches['s']);
+    }
+
+    return $datetime;
+  }
 
   public function setYear($year) {
     $this->year = $year;
diff --git a/src/parser/calendar/data/PhutilCalendarDateTime.php b/src/parser/calendar/data/PhutilCalendarDateTime.php
--- a/src/parser/calendar/data/PhutilCalendarDateTime.php
+++ b/src/parser/calendar/data/PhutilCalendarDateTime.php
@@ -19,6 +19,12 @@
     return (int)$datetime->format('U');
   }
 
+  public function getISO8601() {
+    $datetime = $this->newPHPDateTime();
+    $datetime->setTimezone(new DateTimeZone('UTC'));
+    return $datetime->format('Ymd\\THis\\Z');
+  }
+
   abstract protected function newPHPDateTimeZone();
   abstract protected function newPHPDateTime();
 
diff --git a/src/parser/calendar/data/PhutilCalendarEventNode.php b/src/parser/calendar/data/PhutilCalendarEventNode.php
--- a/src/parser/calendar/data/PhutilCalendarEventNode.php
+++ b/src/parser/calendar/data/PhutilCalendarEventNode.php
@@ -5,11 +5,23 @@
 
   const NODETYPE = 'event';
 
+  private $uid;
   private $name;
   private $description;
   private $startDateTime;
   private $endDateTime;
   private $duration;
+  private $createdDateTime;
+  private $modifiedDateTime;
+
+  public function setUID($uid) {
+    $this->uid = $uid;
+    return $this;
+  }
+
+  public function getUID() {
+    return $this->uid;
+  }
 
   public function setName($name) {
     $this->name = $name;
@@ -29,8 +41,8 @@
     return $this->description;
   }
 
-  public function setStartDateTime(PhutilCalendarDateTime $start_date_time) {
-    $this->startDateTime = $start_date_time;
+  public function setStartDateTime(PhutilCalendarDateTime $start) {
+    $this->startDateTime = $start;
     return $this;
   }
 
@@ -38,8 +50,8 @@
     return $this->startDateTime;
   }
 
-  public function setEndDateTime(PhutilCalendarDateTime $end_date_time) {
-    $this->endDateTime = $end_date_time;
+  public function setEndDateTime(PhutilCalendarDateTime $end) {
+    $this->endDateTime = $end;
     return $this;
   }
 
@@ -69,5 +81,22 @@
     return $this->duration;
   }
 
+  public function setCreatedDateTime(PhutilCalendarDateTime $created) {
+    $this->createdDateTime = $created;
+    return $this;
+  }
+
+  public function getCreatedDateTime() {
+    return $this->createdDateTime;
+  }
+
+  public function setModifiedDateTime(PhutilCalendarDateTime $modified) {
+    $this->modifiedDateTime = $modified;
+    return $this;
+  }
+
+  public function getModifiedDateTime() {
+    return $this->modifiedDateTime;
+  }
 
 }
diff --git a/src/parser/calendar/ics/PhutilICSParser.php b/src/parser/calendar/ics/PhutilICSParser.php
--- a/src/parser/calendar/ics/PhutilICSParser.php
+++ b/src/parser/calendar/ics/PhutilICSParser.php
@@ -92,6 +92,11 @@
     // ICS files are wrapped at 75 characters, with overlong lines continued
     // on the following line with an initial space or tab. Unwrap all of the
     // lines in the file.
+
+    // This unwrapping is specifically byte-oriented, not character oriented,
+    // and RFC5545 anticipates that simple implementations may even split UTF8
+    // characters in the middle.
+
     $last = null;
     foreach ($lines as $idx => $line) {
       $this->cursor = $idx;
@@ -618,6 +623,18 @@
     array $value) {
 
     switch ($name) {
+      case 'UID':
+        $text = $this->newTextFromProperty($parameters, $value);
+        $node->setUID($text);
+        break;
+      case 'CREATED':
+        $datetime = $this->newDateTimeFromProperty($parameters, $value);
+        $node->setCreatedDateTime($datetime);
+        break;
+      case 'DTSTAMP':
+        $datetime = $this->newDateTimeFromProperty($parameters, $value);
+        $node->setModifiedDateTime($datetime);
+        break;
       case 'SUMMARY':
         $text = $this->newTextFromProperty($parameters, $value);
         $node->setName($text);
@@ -667,29 +684,9 @@
     }
 
     $value = head($value);
-
-    $pattern =
-      '/^'.
-      '(?P<y>\d{4})(?P<m>\d{2})(?P<d>\d{2})'.
-      '(?:'.
-        'T(?P<h>\d{2})(?P<i>\d{2})(?P<s>\d{2})(?<z>Z)?'.
-      ')?'.
-      '\z/';
-
-    $matches = null;
-    $ok = preg_match($pattern, $value, $matches);
-    if (!$ok) {
-      $this->raiseParseFailure(
-        self::PARSE_BAD_DATETIME,
-        pht(
-          'Expected DATE-TIME in the format "19990105T112233Z", found '.
-          '"%s".',
-          $value));
-    }
-
     $tzid = $this->getScalarParameterValue($parameters, 'TZID');
 
-    if (isset($matches['z'])) {
+    if (preg_match('/Z\z/', $value)) {
       if ($tzid) {
         $this->raiseWarning(
           self::WARN_TZID_UTC,
@@ -713,17 +710,16 @@
       }
     }
 
-    $datetime = id(new PhutilCalendarAbsoluteDateTime())
-      ->setYear((int)$matches['y'])
-      ->setMonth((int)$matches['m'])
-      ->setDay((int)$matches['d'])
-      ->setTimezone($tzid);
-
-    if (isset($matches['h'])) {
-      $datetime
-        ->setHour((int)$matches['h'])
-        ->setMinute((int)$matches['i'])
-        ->setSecond((int)$matches['s']);
+    try {
+      $datetime = PhutilCalendarAbsoluteDateTime::newFromISO8601(
+        $value,
+        $tzid);
+    } catch (Exception $ex) {
+      $this->raiseParseFailure(
+        self::PARSE_BAD_DATETIME,
+        pht(
+          'Error parsing DATE-TIME: %s',
+          $ex->getMessage()));
     }
 
     return $datetime;
diff --git a/src/parser/calendar/ics/PhutilICSWriter.php b/src/parser/calendar/ics/PhutilICSWriter.php
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/ics/PhutilICSWriter.php
@@ -0,0 +1,228 @@
+<?php
+
+final class PhutilICSWriter extends Phobject {
+
+  public function writeICSDocument(PhutilCalendarRootNode $node) {
+    $out = array();
+
+    foreach ($node->getChildren() as $child) {
+      $out[] = $this->writeNode($child);
+    }
+
+    return implode('', $out);
+  }
+
+  private function writeNode(PhutilCalendarNode $node) {
+    if (!$this->getICSNodeType($node)) {
+      return null;
+    }
+
+    $out = array();
+
+    $out[] = $this->writeBeginNode($node);
+    $out[] = $this->writeNodeProperties($node);
+
+    if ($node instanceof PhutilCalendarContainerNode) {
+      foreach ($node->getChildren() as $child) {
+        $out[] = $this->writeNode($child);
+      }
+    }
+
+    $out[] = $this->writeEndNode($node);
+
+    return implode('', $out);
+  }
+
+  private function writeBeginNode(PhutilCalendarNode $node) {
+    $type = $this->getICSNodeType($node);
+    return $this->wrapICSLine("BEGIN:{$type}");
+  }
+
+  private function writeEndNode(PhutilCalendarNode $node) {
+    $type = $this->getICSNodeType($node);
+    return $this->wrapICSLine("END:{$type}");
+  }
+
+  private function writeNodeProperties(PhutilCalendarNode $node) {
+    $properties = $this->getNodeProperties($node);
+
+    $out = array();
+    foreach ($properties as $property) {
+      $propname = $property['name'];
+      $propvalue = $property['value'];
+
+      $propline = array();
+      $propline[] = $propname;
+
+      foreach ($property['parameters'] as $parameter) {
+        $paramname = $parameter['name'];
+        $paramvalue = 'TODO';
+        $propline[] = ";{$paramname}={$paramvalue}";
+      }
+
+      $propline[] = ":{$propvalue}";
+      $propline = implode('', $propline);
+
+      $out[] = $this->wrapICSLine($propline);
+    }
+
+    return implode('', $out);
+  }
+
+  private function getICSNodeType(PhutilCalendarNode $node) {
+    switch ($node->getNodeType()) {
+      case PhutilCalendarDocumentNode::NODETYPE:
+        return 'VCALENDAR';
+      case PhutilCalendarEventNode::NODETYPE:
+        return 'VEVENT';
+      default:
+        return null;
+    }
+  }
+
+  private function wrapICSLine($line) {
+    $out = array();
+    $buf = '';
+
+    // NOTE: The line may contain sequences of combining characters which are
+    // more than 80 bytes in length. If it does, we'll split them in the
+    // middle of the sequence. This is okay and generally anticipated by
+    // RFC5545, which even allows implementations to split multibyte
+    // characters. The sequence will be stitched back together properly by
+    // whatever is parsing things.
+
+    foreach (phutil_utf8v($line) as $character) {
+      // If adding this character would bring the line over 75 bytes, start
+      // a new line.
+      if (strlen($buf) + strlen($character) > 75) {
+        $out[] = $buf."\n";
+        $buf = ' ';
+      }
+
+      $buf .= $character;
+    }
+
+    $out[] = $buf."\n";
+
+    return implode('', $out);
+  }
+
+  private function getNodeProperties(PhutilCalendarNode $node) {
+    switch ($node->getNodeType()) {
+      case PhutilCalendarDocumentNode::NODETYPE:
+        return array();
+      case PhutilCalendarEventNode::NODETYPE:
+        return $this->getEventNodeProperties($node);
+      default:
+        return array();
+    }
+  }
+
+  private function getEventNodeProperties(PhutilCalendarEventNode $event) {
+    $properties = array();
+
+    $uid = $event->getUID();
+    if (!strlen($uid)) {
+      throw new Exception(
+        pht(
+          'Unable to write ICS document: event has no UID, but each event '.
+          'MUST have a UID.'));
+    }
+    $properties[] = $this->newTextProperty(
+      'UID',
+      $uid);
+
+    $created = $event->getCreatedDateTime();
+    if ($created) {
+      $properties[] = $this->newDateTimeProperty(
+        'CREATED',
+        $event->getCreatedDateTime());
+    }
+
+    $dtstamp = $event->getModifiedDateTime();
+    if (!$dtstamp) {
+      throw new Exception(
+        pht(
+          'Unable to write ICS document: event has no modified time, but '.
+          'each event MUST have a modified time.'));
+    }
+    $properties[] = $this->newDateTimeProperty(
+      'DTSTAMP',
+      $dtstamp);
+
+    $dtstart = $event->getStartDateTime();
+    if ($dtstart) {
+      $properties[] = $this->newDateTimeProperty(
+        'DTSTART',
+        $dtstart);
+    }
+
+    $dtend = $event->getEndDateTime();
+    if ($dtend) {
+      $properties[] = $this->newDateTimeProperty(
+        'DTEND',
+        $event->getEndDateTime());
+    }
+
+    $name = $event->getName();
+    if (strlen($name)) {
+      $properties[] = $this->newTextProperty(
+        'SUMMARY',
+        $name);
+    }
+
+    $description = $event->getDescription();
+    if (strlen($description)) {
+      $properties[] = $this->newTextProperty(
+        'DESCRIPTION',
+        $description);
+    }
+
+    return $properties;
+  }
+
+  private function newTextProperty(
+    $name,
+    $value,
+    array $parameters = array()) {
+
+    $map = array(
+      '\\' => '\\\\',
+      ',' => '\\,',
+      "\n" => '\\n',
+    );
+
+    $value = (array)$value;
+    foreach ($value as $k => $v) {
+      $v = str_replace(array_keys($map), array_values($map), $v);
+      $value[$k] = $v;
+    }
+
+    $value = implode(',', $value);
+
+    return $this->newProperty($name, $value, $parameters);
+  }
+
+  private function newDateTimeProperty(
+    $name,
+    PhutilCalendarDateTime $value,
+    array $parameters = array()) {
+    $datetime = $value->getISO8601();
+    return $this->newProperty($name, $datetime, $parameters);
+  }
+
+  private function newProperty(
+    $name,
+    $value,
+    array $parameters = array()) {
+
+    // TODO: Actually handle parameters.
+
+    return array(
+      'name' => $name,
+      'value' => $value,
+      'parameters' => array(),
+    );
+  }
+
+}
diff --git a/src/parser/calendar/ics/__tests__/PhutilICSWriterTestCase.php b/src/parser/calendar/ics/__tests__/PhutilICSWriterTestCase.php
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/ics/__tests__/PhutilICSWriterTestCase.php
@@ -0,0 +1,61 @@
+<?php
+
+final class PhutilICSWriterTestCase extends PhutilTestCase {
+
+  public function testICSWriter() {
+    $teas = array(
+      'earl grey tea',
+      'English breakfast tea',
+      'black tea',
+      'green tea',
+      't-rex',
+      'oolong tea',
+      'mint tea',
+      'tea with milk',
+    );
+
+    $teas = implode(', ', $teas);
+
+    $event = id(new PhutilCalendarEventNode())
+      ->setUID('tea-time')
+      ->setName('Tea Time')
+      ->setDescription(
+        "Tea and, perhaps, crumpets.\n".
+        "Your presence is requested!\n".
+        "This is a long list of types of tea to test line wrapping: {$teas}.")
+      ->setCreatedDateTime(
+        PhutilCalendarAbsoluteDateTime::newFromISO8601('20160915T070000Z'))
+      ->setModifiedDateTime(
+        PhutilCalendarAbsoluteDateTime::newFromISO8601('20160915T070000Z'))
+      ->setStartDateTime(
+        PhutilCalendarAbsoluteDateTime::newFromISO8601('20160916T150000Z'))
+      ->setEndDateTime(
+        PhutilCalendarAbsoluteDateTime::newFromISO8601('20160916T160000Z'));
+
+    $ics_data = $this->writeICSSingleEvent($event);
+
+    $this->assertICS('writer-tea-time.ics', $ics_data);
+  }
+
+  private function writeICSSingleEvent(PhutilCalendarEventNode $event) {
+    $calendar = id(new PhutilCalendarDocumentNode())
+      ->appendChild($event);
+
+    $root = id(new PhutilCalendarRootNode())
+      ->appendChild($calendar);
+
+    return $this->writeICS($root);
+  }
+
+  private function writeICS(PhutilCalendarRootNode $root) {
+    return id(new PhutilICSWriter())
+      ->writeICSDocument($root);
+  }
+
+  private function assertICS($name, $actual) {
+    $path = dirname(__FILE__).'/data/'.$name;
+    $data = Filesystem::readFile($path);
+    $this->assertEqual($data, $actual, pht('ICS: %s', $name));
+  }
+
+}
diff --git a/src/parser/calendar/ics/__tests__/data/writer-tea-time.ics b/src/parser/calendar/ics/__tests__/data/writer-tea-time.ics
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/ics/__tests__/data/writer-tea-time.ics
@@ -0,0 +1,14 @@
+BEGIN:VCALENDAR
+BEGIN:VEVENT
+UID:tea-time
+CREATED:20160915T070000Z
+DTSTAMP:20160915T070000Z
+DTSTART:20160916T150000Z
+DTEND:20160916T160000Z
+SUMMARY:Tea Time
+DESCRIPTION:Tea and\, perhaps\, crumpets.\nYour presence is requested!\nThi
+ s is a long list of types of tea to test line wrapping: earl grey tea\, En
+ glish breakfast tea\, black tea\, green tea\, t-rex\, oolong tea\, mint te
+ a\, tea with milk.
+END:VEVENT
+END:VCALENDAR